gigafibre-fsm/apps/ops/src/components/shared/detail-sections/IssueDetail.vue
louispaulb 788d969090 chore(reconcile): commit MES hunks déployés dans les fichiers co-édités (split via git apply --cached)
Réconciliation de l'audit : isole et commite UNIQUEMENT mon travail déployé-non-commité dans 7 fichiers co-édités,
sans toucher au working tree (donc sans capturer le travail en cours des autres sessions, qui reste non commité) :
- server.js : SSE event 'ready' + retry:3000 ; routes /olt/onu/plan|run + /olt/wifi-clients.
- conversation.js : endpoint /conversations/my-inbox-counts (accueil : en attente / suivis / en retard).
- useConversations.js : armSSE (reconnexion+resync+repli poll 25s) + cleanup.
- PlanificationPage.vue : chips géofence (Lane 1c) + sélecteur « Générer l'horaire » (Move 3) + isLate (arrivée en retard).
- SettingsPage.vue : section « Thème & couleurs » (ThemeEditor).
- IssueDetail.vue + ConversationPanel.vue : props source-issue/customer/service-location/phone (« Proposer au client »).
EXCLUS (restent non commités, propriété d'autres sessions) : /service-location, /conversations/msg-visibility, retrait
champ Mapbox, fix conv-message 'belongs', hiérarchie parent_incident, JobMediaModule, etc. Vérifié : 0 marqueur étranger
dans le staged. Tout est déjà LIVE ; ceci n'aligne que le dépôt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:06:47 -04:00

1123 lines
56 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- Quick actions -->
<!-- Flow retiré : la relance de flow sera pilotée par l'automatisation (Réglages généraux), pas manuellement depuis le ticket. -->
<div class="issue-actions q-mb-sm row items-center q-gutter-xs">
<TicketStatusMenu :status="doc.status" @set-status="onSetStatus" />
<TicketStatusControl doctype="Issue" :docname="docName" :status="doc.status"
@changed="p => { if (p && p.status) doc.status = p.status; $emit('reply-sent', docName) }" />
<!-- Fulfillment vente : complète l'installation liée active l'abonnement + facture prorata BROUILLON (flux existant, gaté) -->
<q-btn v-if="canActivate" dense unelevated no-caps color="deep-purple-6" icon="paid" :label="'Activer + facture brouillon' + (actPreview && actPreview.prorated_total != null ? ' (' + actPreview.prorated_total + '$)' : '')" :loading="activating" @click="activateSale">
<q-tooltip>{{ (actPreview.lines || []).length }} abonnement(s) en attente → active + facture prorata BROUILLON (à approuver). Complète l'installation « {{ saleJob.subject || saleJob.name }} ».</q-tooltip>
</q-btn>
</div>
<!-- Client lié (comme une conversation : cherchable par nom, association 1 clic).
Masqué quand on ouvre le ticket DEPUIS la fiche client (redondant : on connaît déjà le client). -->
<div v-if="!hideCustomerLink" class="cust-banner q-mb-sm">
<q-icon name="person" size="18px" :color="doc.customer ? 'green-7' : 'grey-5'" />
<template v-if="doc.customer">
<router-link class="cust-name erp-link" :to="'/clients/' + encodeURIComponent(doc.customer)" style="text-decoration:underline;text-underline-offset:2px">{{ customerLabel || doc.customer }}<q-icon name="open_in_new" size="12px" class="q-ml-xs" /></router-link>
<q-btn flat dense round size="xs" icon="link_off" color="grey-6" :loading="linkingCust" @click="unlinkCustomer">
<q-tooltip>Délier le client</q-tooltip>
</q-btn>
</template>
<q-select v-else dense borderless use-input hide-dropdown-icon input-debounce="300"
:options="custOptions" option-value="value" option-label="label" emit-value map-options
:loading="loadingCust" placeholder="Lier à un compte client (nom)…" style="flex:1;min-width:170px"
@filter="searchCustomers" @update:model-value="linkCustomer">
<template #option="scope">
<q-item v-bind="scope.itemProps" dense>
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
<q-item-label caption>{{ scope.opt.sub }}</q-item-label>
</q-item-section>
</q-item>
</template>
<template #no-option>
<q-item dense><q-item-section class="text-caption text-grey-6">Tapez un nom de client…</q-item-section></q-item>
</template>
</q-select>
</div>
<!-- Technicien(s) assigné(s) — surfacé EN TÊTE (avant : enfoui dans l'arbre des tâches / absent du panneau) -->
<div v-if="assignedPeople.length || dispatchJobs.length" class="tech-banner q-mb-sm">
<q-icon name="engineering" size="18px" :color="assignedPeople.length ? 'indigo-6' : 'grey-5'" />
<span class="tech-banner-lbl">Technicien</span>
<template v-if="assignedPeople.length">
<q-chip v-for="p in assignedPeople" :key="p" dense color="indigo-1" text-color="indigo-9"><UserAvatar :name="p" :size="20" card class="q-mr-xs" />{{ p }}</q-chip>
</template>
<span v-else class="text-caption text-grey-6">Non assigné</span>
</div>
<!-- Assignation MULTIFONCTION (À = assigné · CC = assistant · # = follower) — champ PARTAGÉ, réutilisé depuis PlanificationPage.
Pool par défaut = utilisateurs assignables du ticket (le ticket assigne des Users, pas des tech_id) ; « Assist » / « #follow » ouvrent tous les utilisateurs. -->
<div class="assign-block q-mb-sm">
<div class="info-block-title" style="margin-bottom:4px">Assignation</div>
<AssignmentField doctype="Issue" :doc-name="docName"
:assignee="issueAssignee" :assistants="issueAssistants" :tech-options="unifiedUserOptions" :can-onsite="false"
@assign="assignUser" @set-assistant="p => assignUser(p.value)" @unassign="unassignFirst" @remove-assistant="removeAssignee" />
</div>
<div class="issue-field-grid">
<div class="if-row">
<span class="if-label">Statut</span>
<InlineField :value="doc.status" field="status" doctype="Issue" :docname="docName"
type="select" :options="['Open', 'Replied', 'On Hold', 'Resolved', 'Closed']"
@saved="v => doc.status = v.value" />
</div>
<div class="if-row">
<span class="if-label">Priorite</span>
<InlineField :value="doc.priority" field="priority" doctype="Issue" :docname="docName"
type="select" :options="['Low', 'Medium', 'High', 'Urgent']"
@saved="v => doc.priority = v.value" />
</div>
<div class="if-row">
<span class="if-label">Ouvert le</span>
<span class="if-value">{{ formatDate(doc.opening_date) }}</span>
</div>
<div class="if-row" v-if="doc.resolution_time">
<span class="if-label">Resolu le</span>
<span class="if-value">{{ doc.resolution_time }}</span>
</div>
<div class="if-row">
<span class="if-label">Type</span>
<InlineField :value="doc.issue_type" field="issue_type" doctype="Issue" :docname="docName"
type="select" :options="['Support', 'Installation', 'Demenagement', 'Facturation', 'Reseau', 'Autre']"
placeholder="Type" @saved="v => doc.issue_type = v.value" />
</div>
<div class="if-row" v-if="doc.service_location">
<span class="if-label">Adresse</span>
<span class="if-value">{{ doc.service_location }}</span>
</div>
<div class="if-row" v-if="doc.raised_by">
<span class="if-label">Soumis par</span>
<span class="if-value" style="display:flex;align-items:center;gap:6px;min-width:0">
<UserAvatar :email="doc.raised_by" :name="doc.raised_by" :size="20" card />
<span class="ellipsis">{{ doc.raised_by }}</span>
</span>
</div>
</div>
<!-- Tags — SOURCE UNIQUE (Dispatch Tag compétences roster) via TagEditor partagé.
Avant : q-select maison + liste Dispatch Tag seule (sans les compétences « sans-fil »…). -->
<div class="q-mt-sm">
<div class="info-block-title" style="display:flex;align-items:center">
Tags
<q-btn flat dense round size="xs" icon="settings" color="grey-5" class="q-ml-xs"
@click="showTagManager = !showTagManager">
<q-tooltip>Gérer les tags</q-tooltip>
</q-btn>
</div>
<TagEditor :model-value="currentTags" :all-tags="allTags" :get-color="getTagColor"
:can-edit="false" placeholder="Ajouter un tag (compétence, département…)…"
@update:model-value="onTagsChanged" @create="onCreateTag" />
<!-- Tag Manager (CRUD Dispatch Tag) -->
<div v-if="showTagManager" class="tag-manager q-mt-sm">
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Gérer les tags ({{ dispatchTags.length }})</div>
<div v-for="t in dispatchTags" :key="t.name" class="tag-mgr-row">
<div class="tag-mgr-dot" :style="{ background: t.color || '#6b7280' }"
@click="cycleTagColor(t)">
<q-tooltip>Changer la couleur</q-tooltip>
</div>
<span v-if="editingTagName !== t.name" class="tag-mgr-label" @dblclick="startEditTag(t)">{{ t.label }}</span>
<input v-else v-model="editingTagValue" class="tag-mgr-edit"
@keyup.enter="saveTagRename(t)" @keyup.escape="editingTagName = null" @blur="saveTagRename(t)" />
<span class="tag-mgr-cat text-caption text-grey-5">{{ t.category }}</span>
<q-btn flat dense round size="xs" icon="delete" color="negative"
@click="deleteDispatchTag(t)" class="tag-mgr-del">
<q-tooltip>Supprimer ce tag</q-tooltip>
</q-btn>
</div>
<div v-if="!dispatchTags.length" class="text-caption text-grey-5">Aucun tag</div>
</div>
</div>
<!-- ═══ DISPATCH / PROJECT TASKS ═══ -->
<div class="q-mt-md dispatch-section">
<div class="row items-center q-mb-xs">
<div class="info-block-title" style="margin-bottom:0">
<q-icon name="account_tree" size="16px" class="q-mr-xs" />
Tâches ({{ dispatchJobs.length }})
</div>
<q-space />
<!-- Un SEUL point d'entrée « Ajouter » (plus deux icônes côte à côte) : tâche simple, ou projet multi-étapes d'où découlent les tâches. -->
<q-btn-dropdown dense size="sm" color="primary" no-caps unelevated icon="add" label="Ajouter">
<q-list dense style="min-width:230px">
<q-item clickable v-close-popup @click="showCreateDialog = true">
<q-item-section avatar><q-icon name="add_task" color="primary" /></q-item-section>
<q-item-section><q-item-label>Tâche</q-item-label><q-item-label caption>Tâche simple liée à ce ticket</q-item-label></q-item-section>
</q-item>
<q-item clickable v-close-popup @click="showProjectWizard = true">
<q-item-section avatar><q-icon name="playlist_add" color="green-7" /></q-item-section>
<q-item-section><q-item-label>Projet</q-item-label><q-item-label caption>Modèle multi-étapes (installation…) — les tâches en découlent</q-item-label></q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="availReasonOpen = true">
<q-item-section avatar><q-icon name="fact_check" color="indigo-7" /></q-item-section>
<q-item-section><q-item-label>Dispo par motif</q-item-label><q-item-label caption>Compétence requise → disponibilité ajustée, puis créer la tâche</q-item-label></q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<!-- Nested task tree -->
<div v-if="dispatchJobs.length" class="dispatch-jobs-list">
<!-- Root jobs (no parent) -->
<template v-for="job in rootJobs" :key="job.name">
<TaskNode :job="job" :all-jobs="dispatchJobs" :depth="0"
@fire-webhook="fireWebhook" @deleted="onJobDeleted" @updated="onJobUpdated" />
</template>
<!-- Orphan jobs (parent_job references something outside this ticket) -->
<template v-for="job in orphanJobs" :key="job.name">
<TaskNode :job="job" :all-jobs="dispatchJobs" :depth="0"
@fire-webhook="fireWebhook" @deleted="onJobDeleted" @updated="onJobUpdated" />
</template>
</div>
<div v-else class="text-caption text-grey-5 q-pa-sm">
Aucune tâche liée à ce ticket.
</div>
</div>
<!-- ═══ INTERVENTION SUR SITE / DISPATCH ═══
Modules RÉUTILISABLES (identiques au volet Planification) montés pour la tâche dispatch principale.
Auto-affiché si signaux terrain (coords / billet legacy) ; sinon bascule manuelle. « Ne régresse pas vers un DetailModal nu ». -->
<div v-if="primaryJob" class="q-mt-md onsite-section">
<div class="row items-center q-mb-xs">
<div class="info-block-title" style="margin-bottom:0"><q-icon name="construction" size="16px" class="q-mr-xs" />Intervention sur site</div>
<q-space />
<q-toggle v-model="showOnsite" dense size="sm" color="teal-6" label="Sur place">
<q-tooltip>Afficher les détails terrain (carte, suivi GPS, durée, compétences, fil du billet) pour cette intervention</q-tooltip>
</q-toggle>
</div>
<div v-if="showOnsite" class="onsite-body">
<JobMapModule :name="primaryJob.name" :lat="primaryJob.latitude" :lon="primaryJob.longitude"
:tech-id="primaryJob.assigned_tech || ''" :iso="primaryJobIso" :can-track="jobCanTrack" :today-iso="todayIso" />
<GeofenceTimeline :name="primaryJob.name" />
<DurationField :name="primaryJob.name" :dur-h="Number(primaryJob.duration_h) || 1" />
<RequiredSkillsField :name="primaryJob.name" :skills="primaryJobSkills" :all-tags="allTags" :get-color="getTagColor" @create="onCreateTag" />
<!-- Équipe / assignation de la tâche (module partagé). Lead via roster.assignJob (chemin simple, pas la pipeline board). -->
<div class="q-mt-sm">
<JobTeamField :name="primaryJob.name" :tech-id="primaryJob.assigned_tech || ''" :tech-name="primaryJobTechName"
:tech-options="jobTechOptions" :can-onsite="true"
@assign-lead="onJobAssignLead" @unassign-lead="onJobUnassignLead" />
</div>
<template v-if="primaryJob.legacy_ticket_id">
<JobThread ref="jobThreadRef" :lid="primaryJob.legacy_ticket_id" />
<JobReplyBox :name="primaryJob.name" :lid="primaryJob.legacy_ticket_id" @sent="() => jobThreadRef && jobThreadRef.reload()" />
</template>
</div>
</div>
<div class="q-mt-sm">
<div class="info-block-title">Sujet</div>
<InlineField :value="doc.subject" field="subject" doctype="Issue" :docname="docName"
placeholder="Sujet du ticket" @saved="v => doc.subject = v.value" />
</div>
<div v-if="doc.issue_split_from" class="q-mt-md">
<div class="info-block-title">Ticket parent</div>
<div class="text-body2 erp-link" style="cursor:pointer" @click="$emit('navigate', 'Issue', doc.issue_split_from)">
{{ doc.issue_split_from }}
</div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Description</div>
<InlineField :value="doc.description || ''" field="description" doctype="Issue" :docname="docName"
type="textarea" placeholder="Ajouter une description..."
@saved="v => doc.description = v.value" />
</div>
<div v-if="doc.resolution_details" class="q-mt-md">
<div class="info-block-title">Resolution</div>
<div class="modal-desc" v-html="richHtml(doc.resolution_details)"></div>
</div>
<!-- Fil unifié : échanges (Communication) + notes (Comment) fusionnés, chronologique, en bulles -->
<div v-if="thread.length" class="q-mt-md">
<div class="info-block-title">Conversation ({{ thread.length }})</div>
<div class="thread-wrap">
<div v-for="m in thread" :key="m.id" class="msg-bubble" :class="'msg-' + m.kind">
<div class="msg-head">
<UserAvatar :email="m.email" :name="m.who" :size="22" card class="q-mr-xs" />
<span class="msg-who">{{ m.who }}</span>
<!-- Pastille de visibilité CLIQUABLE : bascule Interne ↔ Public (relabel seul, aucun courriel) -->
<span class="msg-vis" :class="[m.public ? 'vis-pub' : 'vis-int', { 'vis-busy': togglingMsg === m.name }]"
role="button" tabindex="0" @click="toggleMsgVisibility(m)" @keyup.enter="toggleMsgVisibility(m)">
<q-spinner v-if="togglingMsg === m.name" size="11px" />
<q-icon v-else :name="m.public ? 'visibility' : 'lock'" size="11px" />{{ m.public ? 'Client' : 'Interne' }}
<q-tooltip class="bg-grey-9" style="font-size:11px">{{ m.public ? 'Visible par le client — cliquer pour repasser en interne' : 'Note interne (privée) — cliquer pour rendre visible au client (sans courriel)' }}</q-tooltip>
</span>
<span class="msg-when">{{ formatDateTime(m.when) }}</span>
</div>
<div class="msg-body" v-html="richHtml(m.content)"></div>
</div>
</div>
</div>
<div v-if="files.length" class="q-mt-md">
<div class="info-block-title">Pieces jointes ({{ files.length }})</div>
<div v-for="f in files" :key="f.name" class="q-py-xs">
<a :href="erpFileUrl(f.file_url)" target="_blank" class="erp-link">
<q-icon name="attach_file" size="14px" /> {{ f.file_name }}
</a>
</div>
</div>
<div v-if="!doc.description && !doc.resolution_details && !thread.length" class="text-center text-grey-5 q-pa-lg">
Aucun contenu pour ce ticket
</div>
<!-- Delete -->
<div class="q-mt-lg" style="border-top:1px solid #fee2e2;padding-top:12px">
<q-btn flat dense no-caps size="sm" icon="delete" label="Supprimer ce ticket"
color="negative" @click="confirmDelete">
<q-tooltip>Suppression définitive du ticket</q-tooltip>
</q-btn>
</div>
<div class="q-mt-md reply-box">
<div class="info-block-title">Répondre</div>
<q-input v-model="replyContent" dense outlined type="textarea" autogrow
placeholder="Écrire une réponse ou une note…"
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
<!-- UNE seule action + une case « Envoyer au client ». Défaut = interne (privé). Cocher = public + courriel (lien ticket ; la réponse revient au fil). -->
<div class="row items-center justify-between q-mt-xs no-wrap">
<q-checkbox v-model="replyToClient" dense size="sm" :color="replyToClient ? 'deep-orange-9' : 'grey-7'">
<span class="reply-cb-lbl" :class="{ 'reply-cb-pub': replyToClient }">
<q-icon :name="replyToClient ? 'warning_amber' : 'lock'" size="14px" />
{{ replyToClient ? 'Envoyer au client (visible + courriel)' : 'Note interne (privée)' }}
</span>
<q-tooltip class="bg-grey-9" style="font-size:11px">{{ replyToClient ? ' Visible par le CLIENT : marqué public + courriel avec lien vers le ticket (sa réponse revient au fil)' : 'Reste PRIVÉ non visible par le client. Cochez pour envoyer au client.' }}</q-tooltip>
</q-checkbox>
<q-btn unelevated dense size="sm" :label="replyToClient ? 'Envoyer au client' : 'Ajouter la note'"
:color="replyToClient ? 'deep-orange-9' : 'grey-8'" :icon="replyToClient ? 'send' : 'lock'"
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply" />
</div>
</div>
<!-- Create single Dispatch Job dialog -->
<UnifiedCreateModal
v-model="showCreateDialog"
mode="work-order"
:context="dispatchContext"
:existing-jobs="dispatchJobs"
@created="onJobCreated"
/>
<!-- Project Wizard (multi-step template) -->
<ProjectWizard
v-model="showProjectWizard"
:issue="doc"
:existing-jobs="dispatchJobs"
@created="onJobCreated"
/>
<!-- « Dispo par motif » : raison (sujet/description pré-remplis) compétence disponibilité filtrée créer la tâche -->
<AvailabilityByReason
v-model="availReasonOpen"
:initial-text="[doc.subject, doc.description].filter(Boolean).join(' ')"
:customer-name="customerLabel || doc.customer || ''"
:source-issue="doc.name || docName || ''"
:customer="doc.customer || ''"
:service-location="doc.service_location || ''"
@book="onAvailBook"
/>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Notify, Dialog } from 'quasar'
import { richHtml } from 'src/utils/sanitize'
import { formatDate, formatDateTime, erpFileUrl } from 'src/composables/useFormatters'
import { createDoc, updateDoc, deleteDoc, listDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { useAuthStore } from 'src/stores/auth'
import { usePermissions } from 'src/composables/usePermissions'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
import { useTagCatalog } from 'src/composables/useTagCatalog'
import InlineField from 'src/components/shared/InlineField.vue'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
import TaskNode from 'src/components/shared/TaskNode.vue'
import TagEditor from 'src/components/shared/TagEditor.vue'
import AssignmentField from 'src/components/shared/AssignmentField.vue'
import UserAvatar from 'src/components/shared/UserAvatar.vue'
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison → compétence → disponibilité (dénominateur filtré)
import { useIdentity } from 'src/composables/useIdentity' // identité unifiée : dé-dup assignation (alias/tech = même personne) + nom complet
import TicketStatusMenu from 'src/components/shared/TicketStatusMenu.vue' // bouton « Statut » (Complété/Annulé) — distinct du snooze « Reporter »
import { setJobStatusChain, activationPreview } from 'src/api/dispatch' // complète le job lié → active abonnement + facture brouillon (flux existant, gaté) + aperçu lecture seule
// Modules on-site / dispatch RÉUTILISABLES (mêmes composants que le volet Planification) — montés ici pour la tâche dispatch principale du ticket.
import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue'
import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue'
import DurationField from 'src/components/shared/detail-sections/modules/DurationField.vue'
import RequiredSkillsField from 'src/components/shared/detail-sections/modules/RequiredSkillsField.vue'
import JobThread from 'src/components/shared/detail-sections/modules/JobThread.vue'
import JobReplyBox from 'src/components/shared/detail-sections/modules/JobReplyBox.vue'
import JobTeamField from 'src/components/shared/detail-sections/modules/JobTeamField.vue'
import * as roster from 'src/api/roster'
const props = defineProps({
doc: { type: Object, required: true },
docName: String,
title: String,
comments: { type: Array, default: () => [] },
comms: { type: Array, default: () => [] },
files: { type: Array, default: () => [] },
dispatchJobs: { type: Array, default: () => [] },
hideCustomerLink: { type: Boolean, default: false }, // true = ouvert depuis la fiche client → masque la bannière client (redondante)
})
const emit = defineEmits(['navigate', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated', 'deleted'])
const replyContent = ref('')
const authStore = useAuthStore()
const { userName } = usePermissions()
// Expéditeur = PRÉNOM de l'utilisateur connecté (note interne) / prénom+nom (sortie courriel au client) — jamais le courriel/login brut (« louis »).
const senderFirst = computed(() => { const n = (userName.value || '').trim(); return (n ? n.split(/\s+/)[0] : (authStore.user || '').split('@')[0]) || 'Agent' })
const senderFull = computed(() => ((userName.value || '').trim() || (authStore.user || '').split('@')[0] || 'Agent'))
const sendingReply = ref(false)
const replyToClient = ref(false) // case à cocher : OFF = note interne (défaut sûr) · ON = public + envoi au client
const EMAIL_RE = /.+@.+\..+/
// Ticket INITIÉ par le client ? (soumis par un courriel externe, OU le client a écrit dans le fil).
// → si oui, la case « Envoyer au client » est PRÉ-COCHÉE (mais tout envoi public exige une confirmation à la soumission).
// Ticket interne (créé par un agent) → case décochée par défaut = note interne.
const clientInitiated = computed(() => {
const me = (authStore.user || '').toLowerCase()
const rb = String(props.doc?.raised_by || '').trim().toLowerCase()
if (rb && EMAIL_RE.test(rb) && rb !== me) return true
return (props.comms || []).some(c => String(c.sent_or_received || '') === 'Received')
})
const clientEmailHint = computed(() => { const rb = props.doc?.raised_by; return (rb && EMAIL_RE.test(rb)) ? rb : '' })
// Cale le défaut de la case à l'ouverture d'un ticket (client → coché · interne → décoché). Ne clobbe pas un basculement manuel en cours.
watch(() => props.docName, () => { replyToClient.value = clientInitiated.value }, { immediate: true })
const showCreateDialog = ref(false)
const showProjectWizard = ref(false)
const availReasonOpen = ref(false)
const bookedSkill = ref('') // compétence résolue par « Dispo par motif » → pré-remplit la tâche créée
const dispatchContext = computed(() => ({
subject: props.doc?.subject || '',
customer: props.doc?.customer || '',
source_issue: props.doc?.name || '',
service_location: props.doc?.service_location || '',
issue_type: props.doc?.issue_type || '',
priority: props.doc?.priority || 'medium',
tags: bookedSkill.value ? [bookedSkill.value] : [], // compétence issue de « Dispo par motif » → compétence requise de la tâche
}))
// « Dispo par motif » a résolu une compétence → pré-remplit + ouvre la création de tâche liée à ce ticket.
function onAvailBook (b) { bookedSkill.value = b.primary || ''; showCreateDialog.value = true }
// ── Client lié + fil unifié ──
const custOptions = ref([])
const loadingCust = ref(false)
const linkingCust = ref(false)
const customerLabel = ref('')
function initials (name) {
if (!name) return '?'
const p = String(name).replace(/@.*/, '').split(/[\s._-]+/).filter(Boolean)
return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase() || '?'
}
// ── Visibilité PAR MESSAGE, surchargeable au clic (Interne privé ↔ Public client) ──
// Défaut par doctype : Communication = échange CLIENT (public) · Comment = note INTERNE (privé). Mais on peut
// BASCULER un message précis : la surcharge explicite est stockée au hub (overlay, clé = docname du message) car
// les Communication mêlent notes internes ET courriels → on ne peut PAS trancher au doctype seul. « Rendre public »
// = RELABEL uniquement, AUCUN courriel envoyé ; le client voit les messages publics via le portail/lien à jeton.
const msgPublic = ref({}) // surcharges explicites { [docname]: true|false }
const togglingMsg = ref('')
async function loadMsgVisibility () {
try {
const r = await fetch(`${HUB_URL}/conversations/msg-visibility`).then(x => x.json())
msgPublic.value = (r && r.public) || {}
} catch { /* hub indispo (aperçu) → on garde le défaut par doctype */ }
}
async function toggleMsgVisibility (m) {
if (!m || !m.name || togglingMsg.value) return
const next = !m.public
togglingMsg.value = m.name
try {
const r = await fetch(`${HUB_URL}/conversations/msg-visibility`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: m.name, public: next }),
}).then(x => x.json())
if (r && r.ok) {
msgPublic.value = { ...msgPublic.value, [m.name]: next }
Notify.create({ type: 'positive', icon: next ? 'visibility' : 'lock', message: next ? 'Rendu PUBLIC — visible par le client (aucun courriel envoyé)' : 'Repassé en INTERNE (privé)', timeout: 2200 })
} else Notify.create({ type: 'negative', message: (r && r.error) || 'Échec', timeout: 2500 })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 2500 }) }
finally { togglingMsg.value = '' }
}
// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique.
// Visibilité = surcharge explicite (msgPublic) SI présente, sinon défaut par doctype (Communication=public, Comment=interne).
const thread = computed(() => {
const items = []
const ov = msgPublic.value
for (const c of props.comms || []) items.push({ id: 'e-' + c.name, name: c.name, email: c.sender || c.owner || '', who: c.sender_full_name || c.sender || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm', public: (c.name in ov) ? !!ov[c.name] : true })
for (const c of props.comments || []) items.push({ id: 'c-' + c.name, name: c.name, email: c.comment_email || c.comment_by || '', who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note', public: (c.name in ov) ? !!ov[c.name] : false })
return items.sort((a, b) => String(a.when || '').localeCompare(String(b.when || '')))
})
async function loadCustomerLabel () {
customerLabel.value = ''
if (!props.doc?.customer) return
try {
const rows = await listDocs('Customer', { filters: { name: props.doc.customer }, fields: ['customer_name'], limit: 1 })
customerLabel.value = rows?.[0]?.customer_name || ''
} catch { /* garde l'ID affiché */ }
}
watch(() => props.doc?.customer, loadCustomerLabel, { immediate: true })
function searchCustomers (val, update) {
if (!val || val.length < 2) { update(() => { custOptions.value = [] }); return }
update(async () => {
loadingCust.value = true
try {
const rows = await listDocs('Customer', {
filters: { customer_name: ['like', '%' + val + '%'] },
fields: ['name', 'customer_name'], limit: 12, orderBy: 'customer_name asc',
})
custOptions.value = rows.map(r => ({ label: r.customer_name || r.name, value: r.name, sub: r.name }))
} catch { custOptions.value = [] }
loadingCust.value = false
})
}
async function linkCustomer (name) {
if (!name) return
linkingCust.value = true
try {
await updateDoc('Issue', props.docName, { customer: name })
props.doc.customer = name
await loadCustomerLabel()
Notify.create({ type: 'positive', message: 'Client lié', timeout: 1500 })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
finally { linkingCust.value = false }
}
async function unlinkCustomer () {
linkingCust.value = true
try {
await updateDoc('Issue', props.docName, { customer: '' })
props.doc.customer = ''
customerLabel.value = ''
Notify.create({ type: 'positive', message: 'Client délié', timeout: 1500 })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
finally { linkingCust.value = false }
}
// ── Assignees ──
const userOptions = ref([])
const loadingUsers = ref(false)
const assignees = ref([])
// Technicien(s) assigné(s) surfacé(s) EN TÊTE : tech des Dispatch Jobs liés (« assigné au job ») + staff legacy (ce que montrent les lignes de liste, absent du panneau jusqu'ici). Dédupliqué.
const assignedPeople = computed(() => {
const seen = new Set(); const out = []
const add = v => { const s = (v == null ? '' : String(v)).trim(); if (s && !seen.has(s.toLowerCase())) { seen.add(s.toLowerCase()); out.push(s) } }
for (const j of (props.dispatchJobs || [])) add(j.assigned_tech)
add(props.doc && props.doc.assigned_staff)
return out
})
function parseAssign (raw) {
if (!raw) return []
try { return JSON.parse(raw).filter(Boolean) } catch { return [] }
}
watch(() => props.doc?._assign, (val) => {
assignees.value = parseAssign(val)
}, { immediate: true })
async function loadUsers () {
if (userOptions.value.length) return
loadingUsers.value = true
try {
const users = await listDocs('User', {
filters: { enabled: 1, user_type: 'System User' },
fields: ['name', 'full_name'],
limit: 100,
orderBy: 'full_name asc',
})
userOptions.value = users.map(u => ({ label: u.full_name || u.name, value: u.name }))
} catch { userOptions.value = [] }
loadingUsers.value = false
}
// Identité unifiée : nom complet + fusion des comptes-alias d'une même personne.
const ident = useIdentity()
// Options SANS doublon d'identité : un seul choix par personne (les alias courriel sont fusionnés),
// libellé = nom canonique (identité) sinon full_name. Recalcule quand la carte d'identité est chargée.
const unifiedUserOptions = computed(() => {
const seen = new Set(); const out = []
for (const o of userOptions.value) {
if (ident.activeOf(o.value) === false) continue // personne partie → non assignable (identité gardée pour l'historique)
const k = ident.keyOf(o.value)
if (seen.has(k)) continue
seen.add(k)
out.push({ label: ident.labelOf(o.value) || o.label, value: o.value })
}
return out
})
const displayName = (email) => ident.labelOf(email) || (userOptions.value.find(u => u.value === email) || {}).label || email
// ── Adaptateurs pour AssignmentField (champ multifonction partagé) ──
// Le ticket assigne des Users via frappe _assign : 1er = assigné (À), suivants = assistants (CC). Followers gérés par le composant (doctype Issue).
const issueAssignee = computed(() => {
const e = assignees.value[0]; if (!e) return null
return { id: e, name: displayName(e) }
})
const issueAssistants = computed(() => assignees.value.slice(1).map(e => ({ id: e, name: displayName(e) })))
async function assignUser (email) {
const e = String(email || '').trim(); if (!e) return
// Dé-dup par IDENTITÉ, pas par courriel : « m'ajouter » (louis@…) + recherche « Louis-Paul » (louispaul@…)
// = la même personne → on n'ajoute pas de doublon (ni le même compte deux fois).
const k = ident.keyOf(e)
if (assignees.value.some(x => ident.keyOf(x) === k)) { Notify.create({ type: 'info', message: displayName(e) + ' est déjà sur ce ticket', timeout: 1800 }); return }
const next = [...assignees.value, e]
assignees.value = next
await saveAssignees(next)
}
async function removeAssignee (email) {
const e = String(email || '').trim(); if (!e) return
const next = assignees.value.filter(x => x !== e)
assignees.value = next
await saveAssignees(next)
}
function unassignFirst () { const e = assignees.value[0]; if (e) return removeAssignee(e) }
async function saveAssignees (newList) {
const old = parseAssign(props.doc._assign)
const added = newList.filter(u => !old.includes(u))
const removed = old.filter(u => !newList.includes(u))
try {
for (const user of added) {
await authFetch(BASE_URL + '/api/method/frappe.desk.form.assign_to.add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Issue', name: props.docName, assign_to: [user] }),
})
}
for (const user of removed) {
await authFetch(BASE_URL + '/api/method/frappe.desk.form.assign_to.remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Issue', name: props.docName, assign_to: user }),
})
}
props.doc._assign = JSON.stringify(newList)
Notify.create({ type: 'positive', message: 'Assignation mise à jour', timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur assignation: ' + e.message, timeout: 3000 })
assignees.value = old
}
}
onMounted(() => { loadUsers(); loadTagCatalog(); loadMsgVisibility() })
// ── Intervention sur site / dispatch : modules montés pour la tâche dispatch PRINCIPALE du ticket ──
// Principale = une tâche avec coordonnées OU billet legacy (aspect terrain) ; sinon la 1re racine ; sinon la 1re.
const jobThreadRef = ref(null)
const primaryJob = computed(() => {
const jobs = props.dispatchJobs || []
if (!jobs.length) return null
return jobs.find(j => (j.latitude && j.longitude) || j.legacy_ticket_id) || jobs.find(j => !j.parent_job) || jobs[0]
})
// ── Statut du ticket (bouton « Statut » : Complété/Annulé) + fulfillment de vente ──
const activating = ref(false)
// Tâche liée encore ouverte (installation à compléter) → cible du bouton « Activer + facture brouillon ».
const saleJob = computed(() => (props.dispatchJobs || []).find(j => !['Completed', 'Cancelled'].includes(j.status)) || null)
// Aperçu d'activation (lecture seule) : abonnements EN ATTENTE du client+lieu + total prorata consolidé (tous services).
// C'est LUI qui définit « quel ticket déclenche une activation » : on n'affiche le bouton que s'il y a du EN ATTENTE.
const actPreview = ref(null)
async function loadActivationPreview () {
const cust = props.doc?.customer; const loc = props.doc?.service_location
if (!cust) { actPreview.value = null; return }
try { const p = await activationPreview({ customer: cust, service_location: loc }); actPreview.value = (p && (p.lines || []).length) ? p : null }
catch (e) { actPreview.value = null }
}
onMounted(loadActivationPreview)
// Le bouton d'activation n'apparaît QUE si (installation liée ouverte) ET (abonnements en attente à activer).
const canActivate = computed(() => !!(saleJob.value && actPreview.value))
async function onSetStatus (status) {
try {
await updateDoc('Issue', props.docName, { status })
props.doc.status = status
Notify.create({ type: 'positive', message: 'Statut : ' + status, timeout: 1400 })
emit('reply-sent', props.docName)
// « Complété » sur une vente avec une installation à compléter → propose l'activation (pas d'auto : l'agent confirme).
if (status === 'Resolved' && canActivate.value) setTimeout(activateSale, 300)
} catch (e) { Notify.create({ type: 'negative', message: 'Statut : ' + (e.message || e) }) }
}
// Réutilise le flux hub EXISTANT : compléter le job lié → déblocage chaîne + activation abonnement + facture prorata BROUILLON
// (gaté PRORATION_WRITE / BILLING_APPROVAL_GATE). N'AUTO-FACTURE jamais : draft à approuver par la facturation.
function activateSale () {
const j = saleJob.value; if (!j || activating.value) return
const p = actPreview.value || {}
const lines = (p.lines || []).map(l => `${l.label}${l.amount != null ? ' — ' + l.amount + '$' : ''}`).join('<br>')
Dialog.create({
title: 'Activer la vente',
message: `Compléter l'installation « ${j.subject || j.name} » et activer <b>${(p.lines || []).length} service(s)</b> en attente ?<br><br>${lines}<br><br><b>Facture prorata consolidée</b>${p.prorated_total != null ? ' ≈ ' + p.prorated_total + '$' : ''}${p.billed_days ? ' (' + p.billed_days + '/' + p.period_days + ' j)' : ''} → créée en <b>BROUILLON</b> (à éditer/approuver par la facturation) si la facturation est armée ; sinon activation sans facture.`,
html: true, cancel: 'Annuler', ok: { label: 'Activer', color: 'deep-purple-6' },
}).onOk(async () => {
activating.value = true
try {
const r = await setJobStatusChain(j.name, 'Completed')
const acts = (r.activated || []).length; const inv = (r.invoices || [])[0]
let msg
if (r.pending_approval) msg = 'Activation placée EN ATTENTE D\'APPROBATION (facturation)'
else if (inv) msg = acts + ' abonnement(s) activé(s) · facture brouillon ' + inv.name
else if (acts) msg = acts + ' abonnement(s) activé(s) · facture non émise (écriture désarmée)'
else msg = 'Installation complétée — aucun abonnement en attente à activer'
Notify.create({ type: 'positive', message: msg, timeout: 6000 })
j.status = 'Completed'; actPreview.value = null; emit('dispatch-updated', j.name)
} catch (e) { Notify.create({ type: 'negative', message: 'Activation : ' + (e.message || e), timeout: 5000 }) } finally { activating.value = false }
})
}
// Signaux « terrain » → affichage AUTO de la section ; sinon bascule manuelle (« nécessite une visite sur place »).
const onsiteAuto = computed(() => {
const j = primaryJob.value; if (!j) return false
const la = +j.latitude, lo = +j.longitude
return (isFinite(la) && isFinite(lo) && (la || lo)) || !!j.legacy_ticket_id
})
const primaryJobIso = computed(() => String((primaryJob.value && primaryJob.value.scheduled_date) || '').slice(0, 10))
const todayIso = computed(() => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }))
const jobCanTrack = computed(() => {
const iso = primaryJobIso.value; if (!iso) return false
const n = todayIso.value
const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
return iso === n || iso === yest
})
const primaryJobSkills = computed(() => { const s = primaryJob.value && primaryJob.value.required_skill; return s ? [s] : [] })
// Équipe / assignation de la tâche dispatch depuis le ticket. Techs chargés à la demande (quand la section s'ouvre).
const jobTechs = ref([])
const jobTechOptions = computed(() => (jobTechs.value || []).map(t => ({ label: t.name + ((t.skills || []).length ? ' · ' + t.skills.slice(0, 4).join(', ') : ''), value: t.id })))
const primaryJobTechName = computed(() => { const id = primaryJob.value && primaryJob.value.assigned_tech; if (!id) return ''; const t = (jobTechs.value || []).find(x => x.id === id); return (t && t.name) || String(id) })
async function loadJobTechs () {
if (jobTechs.value.length) return
try { const r = await roster.listTechnicians(); jobTechs.value = Array.isArray(r) ? r : ((r && r.technicians) || []) } catch { jobTechs.value = [] }
}
// Lead : chemin roster SIMPLE (assignJob / unassign) — pas la pipeline board (assignNames/sibling-grouping) qui vit dans Planification.
async function onJobAssignLead (techId) {
const j = primaryJob.value; if (!techId || !j || !j.name) return
try {
await roster.assignJob(j.name, techId, primaryJobIso.value || undefined)
j.assigned_tech = techId // objet réactif (issu de useDetailModal) → met à jour la bannière + le module
const t = (jobTechs.value || []).find(x => x.id === techId)
Notify.create({ type: 'positive', message: 'Assigné à ' + ((t && t.name) || techId), timeout: 1800 })
emit('dispatch-updated', j.name, { assigned_tech: techId })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur assignation: ' + e.message, timeout: 3000 }) }
}
async function onJobUnassignLead () {
const j = primaryJob.value; if (!j || !j.name) return
try {
await roster.unassignJobRoster(j.name); j.assigned_tech = ''
Notify.create({ type: 'info', message: 'Renvoyé au pool (non assigné)', timeout: 1600 })
emit('dispatch-updated', j.name, { assigned_tech: '' })
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
}
// Bascule persistée par ticket (localStorage — pas de champ ERPNext requis). Défaut = auto-détection.
// ⚠️ showOnsite DOIT être déclaré AVANT tout watch qui le référence (sinon ReferenceError TDZ au setup → composant blanc).
const showOnsite = ref(false)
watch(showOnsite, v => { if (v) loadJobTechs() })
const onsiteKey = () => 'ops-ticket-onsite-' + (props.docName || '')
watch([primaryJob, () => props.docName], () => {
let stored = null
try { stored = localStorage.getItem(onsiteKey()) } catch { /* localStorage indisponible */ }
showOnsite.value = stored != null ? stored === '1' : onsiteAuto.value
}, { immediate: true })
watch(showOnsite, v => { try { localStorage.setItem(onsiteKey(), v ? '1' : '0') } catch { /* noop */ } })
// ── Tags — catalogue UNIFIÉ (Dispatch Tag compétences roster) via TagEditor ──
// Le catalogue et son CRUD vivent dans useTagCatalog (source unique, réutilisable).
const { allTags, dispatchTags, getColor: getTagColor, load: loadTagCatalog, createTag: createCatTag, recolorTag, renameTag: renameCatTag, removeTag: removeCatTag } = useTagCatalog()
function parseTags (raw) {
if (!raw) return []
return raw.split(',').map(t => t.trim()).filter(Boolean)
}
const currentTags = computed(() => parseTags(props.doc?._user_tags))
// Applique/retire une étiquette sur CE ticket (frappe add_tag/remove_tag → Issue._user_tags).
async function tagIssue (label) {
await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.add_tag', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag: label, dt: 'Issue', dn: props.docName }),
})
const cur = parseTags(props.doc._user_tags)
if (!cur.includes(label)) cur.push(label)
props.doc._user_tags = cur.join(',')
}
async function untagIssue (label) {
await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.remove_tag', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag: label, dt: 'Issue', dn: props.docName }),
})
props.doc._user_tags = parseTags(props.doc._user_tags).filter(t => t !== label).join(',')
}
// TagEditor émet la NOUVELLE liste complète de libellés → on diff avec l'actuelle.
function onTagsChanged (newLabels) {
const cur = currentTags.value
const added = newLabels.filter(l => !cur.includes(l))
const removed = cur.filter(l => !newLabels.includes(l))
for (const l of added) tagIssue(l).catch(e => Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }))
for (const l of removed) untagIssue(l).catch(e => Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }))
}
// Création à la volée : matérialise le tag dans Dispatch Tag (l'application au ticket
// arrive séparément via l'émission update:modelValue de TagEditor → onTagsChanged).
async function onCreateTag ({ label, color }) {
try { await createCatTag(label, color) } catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
}
// ── Tag Manager (CRUD Dispatch Tag, délégué à useTagCatalog) ──
const showTagManager = ref(false)
const editingTagName = ref(null)
const editingTagValue = ref('')
const TAG_COLORS = [
'#6366f1', '#3b82f6', '#06b6d4', '#10b981', '#14b8a6',
'#f59e0b', '#f97316', '#f43f5e', '#d946ef', '#8b5cf6',
'#78716c', '#64748b',
]
function startEditTag (t) {
editingTagName.value = t.name
editingTagValue.value = t.label
}
async function saveTagRename (t) {
const newLabel = editingTagValue.value.trim()
editingTagName.value = null
if (!newLabel || newLabel === t.label) return
try {
await renameCatTag(t, newLabel)
Notify.create({ type: 'positive', message: `Tag renommé → "${newLabel}"`, timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
async function cycleTagColor (t) {
const idx = TAG_COLORS.indexOf(t.color)
const next = TAG_COLORS[(idx + 1) % TAG_COLORS.length]
try {
await recolorTag(t, next)
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
async function deleteDispatchTag (t) {
Dialog.create({
title: 'Supprimer le tag ?',
message: `Le tag "${t.label}" sera retiré de tous les éléments.`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'red', label: 'Supprimer', unelevated: true },
}).onOk(async () => {
try {
await removeCatTag(t)
Notify.create({ type: 'positive', message: `Tag "${t.label}" supprimé`, timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
})
}
// ── Delete ──
function confirmDelete () {
Dialog.create({
title: 'Supprimer ce ticket ?',
message: `Le ticket ${props.docName} sera supprimé définitivement.`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'red', label: 'Supprimer', unelevated: true },
persistent: true,
}).onOk(async () => {
try {
await deleteDoc('Issue', props.docName)
Notify.create({ type: 'positive', message: 'Ticket supprimé', timeout: 2000 })
emit('deleted', props.docName)
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
})
}
// ── Nested task tree ──
const rootJobs = computed(() => {
// Jobs with no parent_job, or whose parent_job is not in this ticket's jobs
const jobNames = new Set(props.dispatchJobs.map(j => j.name))
return props.dispatchJobs.filter(j => !j.parent_job || !jobNames.has(j.parent_job))
.sort((a, b) => (a.step_order || 0) - (b.step_order || 0))
})
const orphanJobs = computed(() => {
// Jobs whose parent is in the list but weren't caught as roots or children
// This handles edge cases
const rootAndChildNames = new Set()
function collectNames (jobs) {
for (const j of jobs) {
rootAndChildNames.add(j.name)
}
}
collectNames(rootJobs.value)
// Recursively collect all children
function collectChildren (parentName) {
const children = props.dispatchJobs.filter(j => j.parent_job === parentName)
for (const c of children) {
rootAndChildNames.add(c.name)
collectChildren(c.name)
}
}
for (const r of rootJobs.value) collectChildren(r.name)
return props.dispatchJobs.filter(j => !rootAndChildNames.has(j.name))
})
// ── n8n webhook firing ──
async function fireWebhook (url, job) {
if (!url) return
try {
await authFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: url.includes('close') ? 'job_closed' : 'job_opened',
job_name: job.name,
job_subject: job.subject,
job_status: job.status,
job_type: job.job_type,
ticket: props.doc?.name,
customer: props.doc?.customer,
timestamp: new Date().toISOString(),
}),
})
} catch (err) {
console.warn('[n8n webhook] fire failed:', err)
}
}
function onJobCreated (job) {
emit('dispatch-created', job)
}
function onJobDeleted (jobName) {
emit('dispatch-deleted', jobName)
}
function onJobUpdated (jobName, data) {
emit('dispatch-updated', jobName, data)
}
// Fermer/Rouvrir/Reporter : gérés par le composant partagé TicketStatusControl.
// ── Reply ──
// Courriel du client (destinataire de « Envoyer au client ») : raised_by si c'est un courriel, sinon email_id du compte.
async function resolveClientEmail () {
const rb = props.doc?.raised_by
if (rb && EMAIL_RE.test(rb)) return rb
if (props.doc?.customer) {
try {
const rows = await listDocs('Customer', { filters: { name: props.doc.customer }, fields: ['email_id'], limit: 1 })
const e = rows?.[0]?.email_id
if (e && EMAIL_RE.test(e)) return e
} catch { /* pas d'email → visible au portail seulement */ }
}
return ''
}
// Soumission : une note INTERNE part directement ; un envoi PUBLIC (au client) demande une CONFIRMATION explicite
// (garde-fou, surtout quand la case est pré-cochée pour un ticket initié par le client).
function sendReply () {
if (!replyContent.value?.trim() || sendingReply.value) return
if (!replyToClient.value) { doSendReply(false); return }
Dialog.create({
title: 'Répondre au client ?',
message: `Ce message sera <b>visible par le client</b> et lui sera envoyé par courriel${clientEmailHint.value ? ' (' + clientEmailHint.value + ')' : ''}. Pour une note interne, décochez « Envoyer au client ».`,
html: true,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'deep-orange-9', unelevated: true, label: 'Envoyer au client', icon: 'send' },
}).onOk(() => doSendReply(true))
}
// UNE seule action. Défaut (case décochée) = NOTE INTERNE (privée). Cochée = PUBLIC + courriel au client.
// La visibilité est écrite EXPLICITEMENT dans l'overlay (interne=false / public=true) → corrige le bug « note interne
// affichée Client » (le défaut par doctype classait toute Communication en public).
async function doSendReply (isPublic) {
if (!replyContent.value?.trim() || sendingReply.value) return
const text = replyContent.value.trim()
sendingReply.value = true
try {
const created = await createDoc('Communication', {
communication_type: 'Communication',
communication_medium: 'Other',
sent_or_received: 'Sent',
subject: props.title || props.docName,
content: text,
sender: authStore.user || '',
sender_full_name: senderFull.value, // nom réel de l'agent (jamais le courriel brut)
reference_doctype: 'Issue',
reference_name: props.docName,
})
const name = created && (created.name || (created.data && created.data.name))
// Visibilité explicite (source unique = overlay hub) → rend correctement Interne/Client dès le rechargement.
if (name) {
try {
await fetch(`${HUB_URL}/conversations/msg-visibility`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, public: isPublic }) })
msgPublic.value = { ...msgPublic.value, [name]: isPublic }
} catch { /* best-effort */ }
}
// Public → envoi au client (lien vers le ticket ; sa réponse revient au fil si l'ingestion courriel est active).
let emailed = false
if (isPublic) {
const to = await resolveClientEmail()
if (to) {
try {
const res = await fetch(`${HUB_URL}/conversations/ticket-email`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ issue: props.docName, to, message: text, link: window.location.href, agentName: senderFull.value }),
})
const d = await res.json(); emailed = !!(d && d.ok)
} catch { /* le message reste public/visible même si le courriel échoue */ }
}
}
replyContent.value = ''
replyToClient.value = clientInitiated.value // retour au défaut selon l'origine (client → coché · interne → décoché)
emit('reply-sent', props.docName)
Notify.create({
type: 'positive',
icon: isPublic ? (emailed ? 'send' : 'visibility') : 'lock',
message: isPublic
? (emailed ? 'Envoyé au client (courriel + fil du ticket)' : 'Publié — visible par le client au portail (aucune adresse courriel au dossier)')
: 'Note interne ajoutée (privée)',
timeout: 2800,
})
} catch {
Notify.create({ type: 'negative', message: 'Erreur : réponse non enregistrée', timeout: 3000 })
} finally {
sendingReply.value = false
}
}
</script>
<style scoped>
.issue-actions {
display: flex;
gap: 8px;
}
.issue-field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.if-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
font-size: 0.875rem;
border-bottom: 1px solid #f1f5f9;
min-height: 32px;
}
.if-label {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
width: 80px;
flex-shrink: 0;
}
.if-value {
font-size: 0.875rem;
color: #1f2937;
}
.dispatch-section {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
}
.dispatch-jobs-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.tag-manager {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px 10px;
max-height: 240px;
overflow-y: auto;
}
.tag-mgr-row {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 0;
border-bottom: 1px solid #f1f5f9;
}
.tag-mgr-row:last-child { border-bottom: none; }
.tag-mgr-dot {
width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; cursor: pointer;
transition: transform 0.1s;
}
.tag-mgr-dot:hover { transform: scale(1.3); }
.tag-mgr-label {
flex: 1; font-size: 0.8rem; cursor: pointer; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tag-mgr-label:hover { text-decoration: underline dotted; }
.tag-mgr-edit {
flex: 1; font-size: 0.8rem; border: 1px solid #cbd5e1; border-radius: 4px;
padding: 2px 6px; outline: none; min-width: 0;
}
.tag-mgr-edit:focus { border-color: #6366f1; }
.tag-mgr-cat { flex-shrink: 0; font-size: 0.65rem; }
.tag-mgr-del { opacity: 0.3; transition: opacity 0.15s; }
.tag-mgr-row:hover .tag-mgr-del { opacity: 1; }
/* Client lié */
.cust-banner {
display: flex; align-items: center; gap: 8px;
background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px;
padding: 5px 12px; min-height: 40px;
}
.cust-name { font-weight: 600; font-size: 0.9rem; color: #166534; cursor: pointer; }
.cust-name:hover { text-decoration: underline; }
.tech-banner { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.tech-banner-lbl { font-size: 12px; font-weight: 600; color: #607d8b; }
/* Fil unifié en bulles */
.thread-wrap { display: flex; flex-direction: column; gap: 10px; }
.msg-bubble { border: 1px solid #e5e7eb; border-radius: 10px; padding: 8px 12px; background: #fff; }
.msg-bubble.msg-note { background: #fffbeb; border-color: #fde68a; } /* notes internes = ambre */
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
.msg-avatar {
width: 22px; height: 22px; border-radius: 50%; background: #e0e7ff; color: #4338ca;
font-size: 0.62rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.msg-who { font-size: 0.8rem; font-weight: 600; color: #374151; }
/* Badge visibilité par message (public/client vs interne/privé) */
.msg-vis { display: inline-flex; align-items: center; gap: 2px; font-size: 0.62rem; font-weight: 700; padding: 1px 6px; border-radius: 9px; letter-spacing: .02em; cursor: pointer; user-select: none; transition: filter .12s, box-shadow .12s; }
.msg-vis:hover { filter: brightness(0.96); box-shadow: 0 0 0 1px rgba(0,0,0,.08) inset; }
.msg-vis.vis-busy { opacity: .6; pointer-events: none; }
.msg-vis.vis-int { background: #fef3c7; color: #92400e; } /* interne = ambre (privé) */
.msg-vis.vis-pub { background: #dbeafe; color: #1e40af; } /* client = bleu (public) */
.msg-when { font-size: 0.7rem; color: #9ca3af; margin-left: auto; white-space: nowrap; }
/* Avertissement de visibilité au bas du ticket (avant envoi au client) */
.reply-warn { display: flex; align-items: flex-start; gap: 6px; margin-top: 8px; padding: 7px 10px; font-size: 0.75rem; line-height: 1.35; color: #7c2d12; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
.reply-warn .q-icon { color: #ea580c; flex-shrink: 0; margin-top: 1px; }
.reply-cb-lbl { display: inline-flex; align-items: center; gap: 4px; font-size: 0.78rem; font-weight: 500; color: #475569; }
.reply-cb-lbl.reply-cb-pub { color: #9a3412; font-weight: 600; }
.msg-body { font-size: 0.85rem; color: #1f2937; line-height: 1.5; word-break: break-word; }
.msg-body :deep(img) { max-width: 100%; height: auto; }
</style>