gigafibre-fsm/apps/ops/src/modules/dispatch/components/RightPanel.vue
louispaulb 5e57b72a8f feat(dispatch): couleur ticket = couleur skill + fil complet du ticket + tri pool (date/ville/priorité)
- 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>
2026-06-06 12:37:45 -04:00

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>