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>
1123 lines
56 KiB
Vue
1123 lines
56 KiB
Vue
<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>
|