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>
872 lines
32 KiB
Vue
872 lines
32 KiB
Vue
<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 l’envoi', 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>
|