- Couleurs liées aux skills (éditable/cohérent) : hub deptToSkill() déduit une compétence du type legacy
→ /roster/unassigned-jobs renvoie required_skill ; PlanificationPage colore la carte par getTagColor(required_skill)
(même couleur que le chip skill) ; bordure 5px
- Fil complet du ticket : hub /dispatch/legacy-sync/ticket-thread (ticket_msg + auteur staff, HTML nettoyé) ;
api legacyTicketThread ; RightPanel bouton « 💬 Voir le fil / commentaires » (chargé au clic, messages+auteurs+dates)
- Order-by du pool dispatch : useBottomPanel.bottomSort (date|city|priority) + dropdown ⇅ dans BottomPanel
(ville = 2e segment adresse / token sujet avant |)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
12 KiB
Vue
182 lines
12 KiB
Vue
<script setup>
|
|
import { inject, ref, watch } from 'vue'
|
|
import { fmtDur, prioLabel, prioClass, ICON, legacyReplyUrl } from 'src/composables/useHelpers'
|
|
import { legacyTicketThread } from 'src/api/roster'
|
|
import TagEditor from 'src/components/shared/TagEditor.vue'
|
|
|
|
const props = defineProps({
|
|
panel: Object, // { mode, data: { job, tech } } or null
|
|
})
|
|
|
|
// Fil legacy (description + commentaires/réponses) chargé À LA DEMANDE au clic.
|
|
const thread = ref(null); const threadLoading = ref(false); const threadOpen = ref(false)
|
|
async function toggleThread () {
|
|
const id = props.panel?.data?.job?.legacyTicketId; if (!id) return
|
|
threadOpen.value = !threadOpen.value
|
|
if (!threadOpen.value || thread.value) return
|
|
threadLoading.value = true
|
|
try { thread.value = await legacyTicketThread(id) } catch (e) { thread.value = { error: String(e.message || e) } } finally { threadLoading.value = false }
|
|
}
|
|
function fmtThreadDate (iso) { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) }
|
|
watch(() => props.panel?.data?.job?.legacyTicketId, () => { thread.value = null; threadOpen.value = false }) // reset quand on change de job
|
|
|
|
const emit = defineEmits([
|
|
'close', 'edit', 'move', 'geofix', 'unassign',
|
|
'set-end-date', 'remove-assistant', 'assign-pending',
|
|
'update-tags',
|
|
])
|
|
|
|
const store = inject('store')
|
|
const TECH_COLORS = inject('TECH_COLORS')
|
|
const jobColor = inject('jobColor')
|
|
const todayISO = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) // pour le badge « en retard »
|
|
const getTagColor = inject('getTagColor')
|
|
const onCreateTag = inject('onCreateTag')
|
|
const onUpdateTag = inject('onUpdateTag')
|
|
const onRenameTag = inject('onRenameTag')
|
|
const onDeleteTag = inject('onDeleteTag')
|
|
</script>
|
|
|
|
<template>
|
|
<transition name="sb-slide-right">
|
|
<aside v-if="panel" class="sb-right">
|
|
<div class="sb-rp-hdr">
|
|
<span class="sb-rp-title">{{ {details:'Détails',pending:'Demande entrante'}[panel.mode] || 'Détails' }}</span>
|
|
<button class="sb-rp-close" @click="emit('close')">✕</button>
|
|
</div>
|
|
|
|
<!-- JOB DETAILS -->
|
|
<template v-if="panel.mode==='details'">
|
|
<div class="sb-rp-body">
|
|
<div class="sb-rp-color-bar" :style="'background:'+jobColor(panel.data?.job||{})"></div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Titre</span><strong>{{ panel.data?.job?.subject }}</strong></div>
|
|
<!-- Client + Lieu de service: clickable shortcuts. Customer opens
|
|
the ClientDetailPage in the same SPA (Vue Router hash route);
|
|
Service Location opens in ERPNext desk in a new tab since the
|
|
ops SPA doesn't have a dedicated SL detail page. The address
|
|
below is the free-text on the job; the persisted lat/lng
|
|
(used by the Mapbox marker) lives on the linked Service
|
|
Location — discrepancy = bad geocode at SL creation time. -->
|
|
<div v-if="panel.data?.job?.customer" class="sb-rp-field">
|
|
<span class="sb-rp-lbl">Client</span>
|
|
<a class="sb-rp-link" :href="'#/clients/' + panel.data.job.customer">
|
|
{{ panel.data.job.customer }}
|
|
<span class="sb-rp-link-icon">↗</span>
|
|
</a>
|
|
</div>
|
|
<div v-if="panel.data?.job?.serviceLocation" class="sb-rp-field">
|
|
<span class="sb-rp-lbl">Lieu</span>
|
|
<!-- Lien in-app vers la fiche client, anchored on the SL.
|
|
ClientDetailPage reads ?location= from the query string
|
|
on mount and scrolls to that Service Location section.
|
|
Beats the bare ERPNext desk view which is unfriendly
|
|
(no abonnements, no totaux, just the raw doctype form). -->
|
|
<a class="sb-rp-link"
|
|
:href="'#/clients/' + panel.data.job.customer + '?location=' + panel.data.job.serviceLocation">
|
|
<code>{{ panel.data.job.serviceLocation }}</code>
|
|
<span class="sb-rp-link-icon">↗</span>
|
|
</a>
|
|
</div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.job?.address || '—' }}</div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Durée</span>{{ fmtDur(panel.data?.job?.duration) }}</div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Priorité</span>
|
|
<span :class="prioClass(panel.data?.job?.priority)">{{ prioLabel(panel.data?.job?.priority) }}</span>
|
|
</div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Technicien</span>{{ panel.data?.tech?.fullName || '—' }}</div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Date planifiée</span>
|
|
{{ panel.data?.job?.scheduledDate || '—' }}
|
|
<span v-if="panel.data?.job?.endDate"> → {{ panel.data.job.endDate }}</span>
|
|
<span v-if="panel.data?.job?.scheduledDate && panel.data.job.scheduledDate < todayISO && panel.data?.job?.status !== 'Completed'"
|
|
:style="{ marginLeft:'6px', color:'#fff', background:'#e53935', borderRadius:'4px', padding:'0 5px', fontSize:'10px', fontWeight:700 }">⏰ en retard</span>
|
|
</div>
|
|
<div v-if="panel.data?.job?.assignedTech" class="sb-rp-field">
|
|
<span class="sb-rp-lbl">Date de fin</span>
|
|
<input type="date" class="sb-form-input" :value="panel.data?.job?.endDate || ''"
|
|
@change="emit('set-end-date', panel.data.job, $event.target.value)" style="margin-top:2px" />
|
|
</div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Statut</span>{{ panel.data?.job?.status }}</div>
|
|
<div v-if="panel.data?.job?.legacyTicketId" class="sb-rp-field">
|
|
<span class="sb-rp-lbl">Ticket legacy</span>
|
|
<a class="sb-rp-link" :href="legacyReplyUrl(panel.data.job)" target="_blank" rel="noopener"
|
|
:title="'Répondre / écrire dans le ticket #' + panel.data.job.legacyTicketId + ' (serveur legacy)'">
|
|
#{{ panel.data.job.legacyTicketId }}<span v-if="panel.data?.job?.legacyDept"> · {{ panel.data.job.legacyDept }}</span>
|
|
<span class="sb-rp-link-icon">↗</span>
|
|
</a>
|
|
</div>
|
|
<div v-if="panel.data?.job?.legacyDetail" class="sb-rp-field">
|
|
<span class="sb-rp-lbl">Détails du ticket</span>
|
|
<div style="white-space:pre-wrap;max-height:200px;overflow:auto;font-size:0.78rem;line-height:1.4;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:6px 8px;margin-top:2px">{{ panel.data.job.legacyDetail }}</div>
|
|
</div>
|
|
<!-- Fil complet du ticket legacy : commentaires / réponses des collaborateurs (chargé au clic) -->
|
|
<div v-if="panel.data?.job?.legacyTicketId" class="sb-rp-field">
|
|
<button class="sb-rp-btn" style="width:100%;text-align:left" @click="toggleThread">
|
|
💬 {{ threadOpen ? 'Masquer' : 'Voir' }} le fil du ticket / commentaires
|
|
<span v-if="thread && thread.count != null" style="opacity:.7">({{ thread.count }})</span>
|
|
</button>
|
|
<div v-if="threadOpen" style="margin-top:6px;max-height:300px;overflow:auto">
|
|
<div v-if="threadLoading" style="font-size:.78rem;opacity:.7;padding:4px">Chargement…</div>
|
|
<div v-else-if="thread && thread.error" style="font-size:.78rem;color:#ef4444;padding:4px">Erreur : {{ thread.error }}</div>
|
|
<div v-else-if="thread && !thread.messages?.length" style="font-size:.78rem;opacity:.7;padding:4px">Aucun message.</div>
|
|
<div v-for="(m, i) in (thread?.messages || [])" :key="i" style="border-left:2px solid rgba(255,255,255,0.12);padding:3px 8px;margin-bottom:6px">
|
|
<div style="font-size:.68rem;opacity:.7"><b>{{ m.author }}</b> · {{ fmtThreadDate(m.at) }}</div>
|
|
<div style="white-space:pre-wrap;font-size:.76rem;line-height:1.35">{{ m.text }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="panel.data?.job?.note" class="sb-rp-field"><span class="sb-rp-lbl">Note</span>{{ panel.data.job.note }}</div>
|
|
<div class="sb-rp-field">
|
|
<span class="sb-rp-lbl">Tags</span>
|
|
<TagEditor v-if="panel.data?.job"
|
|
:model-value="panel.data.job.tags || []"
|
|
@update:model-value="v => emit('update-tags', panel.data.job, v)"
|
|
:all-tags="store.allTags" :get-color="getTagColor"
|
|
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
|
|
</div>
|
|
<div v-if="panel.data?.job?.assistants?.length" class="sb-rp-field">
|
|
<span class="sb-rp-lbl">Assistants</span>
|
|
<div v-for="a in panel.data.job.assistants" :key="a.techId" style="display:flex;align-items:center;gap:6px;margin-top:3px">
|
|
<span class="sb-assist-badge" :style="'background:'+TECH_COLORS[store.technicians.find(t=>t.id===a.techId)?.colorIdx||0]">
|
|
{{ (a.techName||'').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
|
|
</span>
|
|
<span style="font-size:0.72rem">{{ a.techName }} · {{ fmtDur(a.duration) }}{{ a.note ? ' · '+a.note : '' }}</span>
|
|
<button style="margin-left:auto;background:none;border:none;color:#ef4444;cursor:pointer;font-size:0.7rem"
|
|
@click="emit('remove-assistant', panel.data.job.id, a.techId)">✕</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="sb-rp-actions">
|
|
<button class="sb-rp-primary" @click="emit('edit', panel.data.job)">✏ Modifier</button>
|
|
<button class="sb-rp-btn" @click="emit('move', panel.data.job, panel.data.tech?.id)">↔ Déplacer / Réassigner</button>
|
|
<button class="sb-rp-btn" @click="emit('geofix', panel.data.job)">📍 Géofixer sur la carte</button>
|
|
<a v-if="legacyReplyUrl(panel.data?.job)" class="sb-rp-btn" :href="legacyReplyUrl(panel.data.job)" target="_blank" rel="noopener" style="text-decoration:none;text-align:center">📝 Répondre dans legacy</a>
|
|
<a v-if="panel.data?.job?.legacyActivationUrl" class="sb-rp-btn" :href="panel.data.job.legacyActivationUrl" target="_blank" rel="noopener" style="text-decoration:none;text-align:center;background:#7e3ff2;color:#fff;border-color:#7e3ff2" title="Connecter / activer le(s) STB sur Ministra (lien legacy du ticket)">📺 Activer STB (Ministra)</a>
|
|
<button v-if="panel.data?.job?.assignedTech" class="sb-rp-btn sb-ctx-warn" @click="emit('unassign', panel.data.job)">✕ Désaffecter</button>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- PENDING REQUEST -->
|
|
<template v-if="panel.mode==='pending'">
|
|
<div class="sb-rp-body">
|
|
<div class="sb-rp-color-bar" :style="'background:'+(panel.data?.urgency==='urgent'?'#ef4444':'#f59e0b')"></div>
|
|
<div v-if="panel.data?.urgency==='urgent'" class="sb-rp-urgent-tag">🚨 Urgent</div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Client</span><strong>{{ panel.data?.customer_name }}</strong></div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Téléphone</span>{{ panel.data?.phone || '—' }}</div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Service</span>{{ panel.data?.service_type }}</div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Problème</span>{{ panel.data?.problem_type || '—' }}</div>
|
|
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.address }}</div>
|
|
<div v-if="panel.data?.budget_label" class="sb-rp-field"><span class="sb-rp-lbl">Budget</span>{{ panel.data?.budget_label }}</div>
|
|
<div class="sbf-title" style="margin-top:0.75rem">Assigner à</div>
|
|
<div class="sb-assign-grid">
|
|
<button v-for="tech in store.technicians" :key="tech.id"
|
|
class="sb-assign-btn" :style="'border-color:'+TECH_COLORS[tech.colorIdx]"
|
|
@click="emit('assign-pending', tech.id)">
|
|
<span class="sb-assign-dot" :style="'background:'+TECH_COLORS[tech.colorIdx]"></span>
|
|
{{ tech.fullName }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</aside>
|
|
</transition>
|
|
</template>
|