gigafibre-fsm/apps/ops/src/components/shared/detail-sections/IssueDetail.vue
louispaulb fcfd63c2e9 ticket: contrôle Statut/Reporter partagé (v1 sur le panneau ticket)
Nouveau TicketStatusControl (doctype-conscient Issue/Dispatch Job) : Reporter +1j/+7j/date · En attente
d'un autre ticket #X · Fermer/Rouvrir. Reporter = On Hold + note datée (job = replanifie scheduled_date).
Posé sur IssueDetail (page ticket + fiche client) en remplacement des boutons Fermer/Rouvrir épars.
Prochaine étape (séquencée) : câbler sur le panneau job (Planification) + convergence des 3 vues (#27).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:55:29 -04:00

872 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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 -->
<div class="issue-actions q-mb-sm">
<TicketStatusControl doctype="Issue" :docname="docName" :status="doc.status"
@changed="p => { if (p && p.status) doc.status = p.status; $emit('reply-sent', docName) }" />
<FlowQuickButton flat dense size="sm" icon="account_tree" label="Flow"
tooltip="Lancer / modifier un flow pour ce ticket"
category="incident" applies-to="Issue" trigger-event="on_issue_opened" />
</div>
<!-- Client lié (comme une conversation : cherchable par nom, association 1 clic) -->
<div class="cust-banner q-mb-sm">
<q-icon name="person" size="18px" :color="doc.customer ? 'green-7' : 'grey-5'" />
<template v-if="doc.customer">
<span class="cust-name erp-link" @click="$emit('navigate', 'Customer', doc.customer)">{{ customerLabel || doc.customer }}</span>
<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>
<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">{{ doc.raised_by }}</span>
</div>
<div class="if-row">
<span class="if-label">Assigné à</span>
<q-select v-model="assignees" dense borderless multiple use-chips emit-value map-options
:options="userOptions" option-value="value" option-label="label"
input-class="editable-input" style="min-width:140px;flex:1"
:loading="loadingUsers"
@update:model-value="saveAssignees">
<template #selected-item="scope">
<q-chip dense removable size="sm" color="green-1" text-color="green-8"
@remove="scope.removeAtIndex(scope.index)">
{{ scope.opt.label || scope.opt }}
</q-chip>
</template>
</q-select>
</div>
</div>
<!-- Tags -->
<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>
<div class="row items-center q-gutter-xs q-mb-xs">
<q-chip v-for="tag in currentTags" :key="tag" dense removable size="sm"
:style="{ background: getTagColor(tag), color: '#fff' }"
@remove="removeTag(tag)">
{{ tag }}
</q-chip>
</div>
<q-select dense outlined use-input input-debounce="100" emit-value map-options
:options="filteredTagOptions" option-value="value" option-label="label"
:model-value="null" :loading="loadingTags"
placeholder="Ajouter un tag..."
style="max-width:250px" input-class="text-caption"
@filter="filterTags" @update:model-value="addTag"
new-value-mode="add-unique" @new-value="addNewTag">
<template #option="scope">
<q-item v-bind="scope.itemProps" dense>
<q-item-section side>
<div style="width:10px;height:10px;border-radius:50%" :style="{ background: scope.opt.color || '#6b7280' }" />
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
<q-item-label caption v-if="scope.opt.category">{{ scope.opt.category }}</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 pour créer un tag</q-item-section>
</q-item>
</template>
</q-select>
<!-- Tag Manager -->
<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 ({{ allDispatchTags.length }})</div>
<div v-for="t in allDispatchTags" :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="!allDispatchTags.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 />
<q-btn-group unelevated dense>
<q-btn dense size="sm" icon="playlist_add" label="Projet"
color="green-7" no-caps @click="showProjectWizard = true">
<q-tooltip>Créer un projet (modèle multi-étapes)</q-tooltip>
</q-btn>
<q-btn dense size="sm" icon="add" label="Tâche"
color="primary" no-caps @click="showCreateDialog = true">
<q-tooltip>Ajouter une tâche simple</q-tooltip>
</q-btn>
</q-btn-group>
</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>
<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">
<span class="msg-avatar">{{ initials(m.who) }}</span>
<span class="msg-who">{{ m.who }}</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" />
<div class="row justify-end q-gutter-sm q-mt-xs">
<q-btn outline dense size="sm" label="Note interne" color="grey-8" icon="sticky_note_2"
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply">
<q-tooltip>Ajoute une note au fil — non envoyée au client</q-tooltip>
</q-btn>
<q-btn unelevated dense size="sm" label="Envoyer par courriel" color="primary" icon="mail" @click="openEmailDialog">
<q-tooltip>Envoyer par courriel ; la réponse du destinataire reviendra dans ce ticket</q-tooltip>
</q-btn>
</div>
</div>
<!-- Aller-retour courriel : envoie le ticket, la réponse revient au fil du ticket -->
<q-dialog v-model="showEmailDialog">
<q-card style="min-width:min(92vw,460px)">
<q-card-section class="row items-center q-pb-none">
<q-icon name="mail" color="primary" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Envoyer par courriel</div>
<q-space /><q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-sm">
<q-input v-model="emailTo" dense outlined label="À (courriel)" type="email" autofocus />
<q-input v-model="emailCc" dense outlined label="Cc (optionnel)" />
<q-input v-model="emailMessage" dense outlined type="textarea" autogrow label="Message" :input-style="{ minHeight: '90px' }" />
<div class="text-caption text-grey-6"><q-icon name="info" size="13px" /> Un lien vers le ticket est ajouté. La réponse du destinataire s'ajoutera automatiquement à ce ticket.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="primary" icon="send" label="Envoyer" :loading="sendingEmail"
:disable="!emailTo || !/.+@.+\..+/.test(emailTo)" @click="sendTicketEmail" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- 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"
/>
</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 { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
import { fetchTags, updateTag, renameTag, deleteTag as deleteTagApi } from 'src/api/dispatch'
import InlineField from 'src/components/shared/InlineField.vue'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.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'
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: () => [] },
})
const emit = defineEmits(['navigate', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated', 'deleted'])
const replyContent = ref('')
const sendingReply = ref(false)
const showCreateDialog = ref(false)
const showProjectWizard = ref(false)
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',
}))
// ── 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() || '?'
}
// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique
const thread = computed(() => {
const items = []
for (const c of props.comms || []) items.push({ id: 'e-' + c.name, who: c.sender || c.sender_full_name || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm' })
for (const c of props.comments || []) items.push({ id: 'c-' + c.name, who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note' })
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([])
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
}
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(); loadDispatchTags() })
// ── Tags (Dispatch Tags shared list) ──
const allDispatchTags = ref([])
const loadingTags = ref(false)
const filteredTagOptions = ref([])
function parseTags (raw) {
if (!raw) return []
return raw.split(',').map(t => t.trim()).filter(Boolean)
}
const currentTags = computed(() => parseTags(props.doc?._user_tags))
function getTagColor (label) {
const t = allDispatchTags.value.find(x => x.label === label || x.name === label)
return t?.color || '#6b7280'
}
function filterTags (val, update) {
update(() => {
const current = parseTags(props.doc?._user_tags)
const all = allDispatchTags.value
.map(t => ({ label: t.label, value: t.label, color: t.color, category: t.category || '' }))
.filter(o => !current.includes(o.value))
if (!val) { filteredTagOptions.value = all; return }
const q = val.toLowerCase()
filteredTagOptions.value = all.filter(o => o.label.toLowerCase().includes(q))
})
}
async function addTag (tag) {
if (!tag) return
const label = typeof tag === 'string' ? tag : tag.value || tag
if (currentTags.value.includes(label)) return
try {
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)
cur.push(label)
props.doc._user_tags = cur.join(',')
Notify.create({ type: 'positive', message: `Tag "${label}" ajouté`, timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
function addNewTag (val, done) {
if (val.length > 1) {
done(val, 'add-unique')
addTag(val)
}
}
async function removeTag (tag) {
try {
await authFetch(BASE_URL + '/api/method/frappe.desk.doctype.tag.tag.remove_tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag, dt: 'Issue', dn: props.docName }),
})
const cur = parseTags(props.doc._user_tags).filter(t => t !== tag)
props.doc._user_tags = cur.join(',')
Notify.create({ type: 'positive', message: `Tag "${tag}" retiré`, timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
async function loadDispatchTags () {
loadingTags.value = true
try {
allDispatchTags.value = await fetchTags()
} catch { allDispatchTags.value = [] }
loadingTags.value = false
}
// ── Tag Manager ──
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 renameTag(t.name, newLabel)
t.label = newLabel
t.name = 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 updateTag(t.name, { color: next })
t.color = 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 deleteTagApi(t.name)
allDispatchTags.value = allDispatchTags.value.filter(x => x.name !== t.name)
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 ──
async function sendReply () {
if (!replyContent.value?.trim() || sendingReply.value) return
sendingReply.value = true
try {
const authStore = useAuthStore()
await createDoc('Communication', {
communication_type: 'Communication',
communication_medium: 'Other',
sent_or_received: 'Sent',
subject: props.title || props.docName,
content: replyContent.value.trim(),
sender: authStore.user || '',
sender_full_name: authStore.user || '',
reference_doctype: 'Issue',
reference_name: props.docName,
})
replyContent.value = ''
emit('reply-sent', props.docName)
Notify.create({ type: 'positive', message: 'Reponse envoyee', timeout: 2000 })
} catch {
Notify.create({ type: 'negative', message: 'Erreur: reponse non envoyee', timeout: 3000 })
} finally {
sendingReply.value = false
}
}
// ── Envoi par courriel (aller-retour : la réponse revient au ticket) ──
const showEmailDialog = ref(false)
const emailTo = ref('')
const emailCc = ref('')
const emailMessage = ref('')
const sendingEmail = ref(false)
function openEmailDialog () {
emailTo.value = (props.doc?.raised_by && /.+@.+\..+/.test(props.doc.raised_by)) ? props.doc.raised_by : ''
emailCc.value = ''
emailMessage.value = replyContent.value?.trim() || ''
showEmailDialog.value = true
}
async function sendTicketEmail () {
if (!emailTo.value || sendingEmail.value) return
sendingEmail.value = true
try {
const res = await fetch(`${HUB_URL}/conversations/ticket-email`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
issue: props.docName, to: emailTo.value.trim(), cc: emailCc.value.trim(),
message: emailMessage.value.trim(), link: window.location.href,
}),
})
const d = await res.json()
if (d.ok) {
Notify.create({ type: 'positive', message: 'Courriel envoyé — la réponse reviendra à ce ticket', timeout: 2800 })
showEmailDialog.value = false
replyContent.value = ''
emit('reply-sent', props.docName)
} else {
Notify.create({ type: 'negative', message: d.error || 'Échec de lenvoi', timeout: 3500 })
}
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3500 })
} finally {
sendingEmail.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; }
/* 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; }
.msg-when { font-size: 0.7rem; color: #9ca3af; margin-left: auto; white-space: nowrap; }
.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>