gigafibre-fsm/apps/ops/src/pages/HistoriquePage.vue
louispaulb 19d31025a6 feat(inbox+historique): sender-identity fix, single-source taxonomy, dispatch history & leaderboards
Inbox identity/grouping (fixes mislabeled threads):
- customerName = real From display name, then a customer matched BY EMAIL,
  then the raw address — never the AI's content-guessed name (a gilles@ relay
  showed as "Sylvie Juteau").
- never group a thread by one of our own domains (support@targo.ca,
  *@targointernet.com): re-ingested outbound was collapsing unrelated threads
  into one mislabeled "Guylaine Gagnon" thread.

Refactor: queues/TYPES/QUEUE_OF/CAT centralized in lib/categories.js (were
drifting across conversation.js + inbox-triage.js; telephonie/television had
no matching triage type). Removed dead export findConversationByEmail.

Feat: addMessage stamps msg.agent on outbound replies -> per-agent stats.

Historique (/historique, HistoriquePage.vue): tournées par technicien (ALL
statuses incl. Completed/Cancelled — the board hid them), tech leaderboard,
inbox leaderboard. Hub: /dispatch/history, /dispatch/leaderboard,
/conversations/leaderboard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:11:47 -04:00

270 lines
15 KiB
Vue

<template>
<q-page class="hist-page">
<div class="hist-head">
<div class="text-h6">Historique &amp; classements</div>
<div class="hist-sub">Retracer la journée de chaque technicien (tous statuts) et reconnaître l'effort de l'équipe.</div>
</div>
<q-tabs v-model="tab" no-caps dense align="left" class="hist-tabs" active-color="primary" indicator-color="primary">
<q-tab name="tournees" icon="route" label="Tournées par technicien" />
<q-tab name="techs" icon="emoji_events" label="Classement techniciens" />
<q-tab name="inbox" icon="forum" label="Classement Inbox" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="tab" animated class="hist-panels">
<!-- ─────────── Tournées par technicien (tous statuts) ─────────── -->
<q-tab-panel name="tournees" class="q-pa-none">
<div class="hist-toolbar">
<q-btn flat dense round icon="chevron_left" @click="shiftDay(-1)" />
<q-btn flat dense no-caps class="hist-datebtn" :label="prettyDate(histDate)" icon-right="event">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="histDate" mask="YYYY-MM-DD" today-btn minimal @update:model-value="() => { loadHistory() }" />
</q-popup-proxy>
</q-btn>
<q-btn flat dense round icon="chevron_right" :disable="histDate >= today" @click="shiftDay(1)" />
<q-btn flat dense no-caps label="Aujourd'hui" class="q-ml-sm" :disable="histDate === today" @click="histDate = today; loadHistory()" />
<q-space />
<div v-if="!histLoading && hist" class="hist-summary">
<b>{{ hist.count }}</b> tâche{{ hist.count > 1 ? 's' : '' }} · <b>{{ (hist.techs || []).length }}</b> technicien{{ (hist.techs || []).length > 1 ? 's' : '' }}
<span v-for="s in histStatusSummary" :key="s.key" class="hist-sumchip" :style="{ color: s.color }">● {{ s.n }} {{ s.label }}</span>
</div>
</div>
<div v-if="histLoading" class="hist-loading"><q-spinner-dots size="32px" color="primary" /></div>
<div v-else-if="!hist || !(hist.techs || []).length" class="hist-empty">
<q-icon name="event_busy" size="32px" class="q-mb-sm" />
<div>Aucune tâche planifiée le {{ prettyDate(histDate) }}.</div>
</div>
<div v-else class="hist-techs">
<q-card v-for="t in hist.techs" :key="t.tech" flat bordered class="hist-techcard">
<div class="hist-techhead">
<q-avatar size="28px" color="blue-grey-1" text-color="blue-grey-8" class="q-mr-sm">{{ initials(t.tech_name) }}</q-avatar>
<div class="hist-techname">{{ t.tech_name }}</div>
<q-space />
<span class="hist-techcount">{{ t.jobs.length }} tâche{{ t.jobs.length > 1 ? 's' : '' }}</span>
</div>
<q-separator />
<div v-for="j in t.jobs" :key="j.name" class="hist-job" :class="{ 'hist-job--cancel': j.status === 'Cancelled' }">
<q-chip dense square :style="{ background: statusMeta(j.status).bg, color: statusMeta(j.status).fg }" class="hist-statuschip">{{ statusMeta(j.status).label }}</q-chip>
<span class="hist-jobtime">{{ jobTime(j) }}</span>
<span class="hist-jobsubj">{{ j.subject || j.job_type || j.name }}</span>
<q-space />
<span v-if="j.duration_h" class="hist-jobdur">{{ j.duration_h }} h</span>
</div>
</q-card>
</div>
</q-tab-panel>
<!-- ─────────── Classement techniciens (jobs complétés) ─────────── -->
<q-tab-panel name="techs" class="q-pa-none">
<div class="hist-toolbar">
<q-btn-toggle v-model="rangeDays" no-caps dense unelevated toggle-color="primary" color="grey-2" text-color="grey-8"
:options="[{ label: '7 jours', value: 7 }, { label: '30 jours', value: 30 }, { label: '90 jours', value: 90 }]"
@update:model-value="loadTechBoard" />
<q-space />
<div class="hist-summary">Jobs <b>complétés</b> · {{ prettyDate(rangeFrom) }} → {{ prettyDate(today) }}</div>
</div>
<div v-if="techLoading" class="hist-loading"><q-spinner-dots size="32px" color="primary" /></div>
<div v-else-if="!techBoard.length" class="hist-empty"><q-icon name="emoji_events" size="32px" class="q-mb-sm" /><div>Aucun job complété sur la période.</div></div>
<div v-else class="hist-board">
<div v-for="(b, i) in techBoard" :key="b.tech" class="hist-rank">
<div class="hist-medal" :class="'hist-medal--' + (i < 3 ? i + 1 : 0)">{{ i < 3 ? ['🥇', '🥈', '🥉'][i] : (i + 1) }}</div>
<q-avatar size="30px" color="blue-grey-1" text-color="blue-grey-8" class="q-mr-sm">{{ initials(b.tech_name) }}</q-avatar>
<div class="hist-rankname">{{ b.tech_name }}</div>
<div class="hist-bar"><div class="hist-bar-fill" :style="{ width: barPct(b.completed, techMax) + '%' }"></div></div>
<div class="hist-rankval">{{ b.completed }}</div>
</div>
</div>
</q-tab-panel>
<!-- ─────────── Classement Inbox (réponses par agent) ─────────── -->
<q-tab-panel name="inbox" class="q-pa-none">
<div class="hist-toolbar">
<q-btn-toggle v-model="rangeDays" no-caps dense unelevated toggle-color="primary" color="grey-2" text-color="grey-8"
:options="[{ label: '7 jours', value: 7 }, { label: '30 jours', value: 30 }, { label: '90 jours', value: 90 }]"
@update:model-value="loadInboxBoard" />
<q-space />
<div class="hist-summary">Réponses &amp; conversations prises · {{ prettyDate(rangeFrom) }} → {{ prettyDate(today) }}</div>
</div>
<div v-if="inboxLoading" class="hist-loading"><q-spinner-dots size="32px" color="primary" /></div>
<template v-else>
<div v-if="!inboxBoard.length" class="hist-empty">
<q-icon name="forum" size="32px" class="q-mb-sm" />
<div>Les statistiques par agent s'accumulent à partir de maintenant.</div>
<div class="hist-empty-sub">Chaque réponse envoyée est désormais attribuée à son auteur·rice.<br>{{ inboxUnattributed }} réponse{{ inboxUnattributed > 1 ? 's' : '' }} antérieure{{ inboxUnattributed > 1 ? 's' : '' }} (avant suivi) ne sont pas attribuées.</div>
</div>
<div v-else class="hist-board">
<div v-for="(b, i) in inboxBoard" :key="b.agent" class="hist-rank">
<div class="hist-medal" :class="'hist-medal--' + (i < 3 ? i + 1 : 0)">{{ i < 3 ? ['🥇', '🥈', '🥉'][i] : (i + 1) }}</div>
<q-avatar size="30px" color="teal-1" text-color="teal-8" class="q-mr-sm">{{ initials(agentName(b.agent)) }}</q-avatar>
<div class="hist-rankname">{{ agentName(b.agent) }}</div>
<div class="hist-bar"><div class="hist-bar-fill hist-bar-fill--teal" :style="{ width: barPct(b.replies, inboxMax) + '%' }"></div></div>
<div class="hist-rankval">{{ b.replies }}<span class="hist-rankval-sub">rép.</span></div>
<div class="hist-rankval2" v-if="b.handled">{{ b.handled }}<span class="hist-rankval-sub">pris</span></div>
</div>
<div v-if="inboxUnattributed" class="hist-note">+ {{ inboxUnattributed }} réponse{{ inboxUnattributed > 1 ? 's' : '' }} antérieure{{ inboxUnattributed > 1 ? 's' : '' }} non attribuée{{ inboxUnattributed > 1 ? 's' : '' }} (avant le suivi par auteur).</div>
</div>
</template>
</q-tab-panel>
</q-tab-panels>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Notify } from 'quasar'
import { HUB_URL } from 'src/config/hub'
const tab = ref('tournees')
function localDateStr (d = new Date()) {
// Date locale (QC = ET) en YYYY-MM-DD, sans décalage UTC
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const today = localDateStr()
async function hubGet (path) {
const res = await fetch(HUB_URL + path, { headers: { 'Content-Type': 'application/json' } })
if (!res.ok) throw new Error('HTTP ' + res.status)
return res.json()
}
// ── Affichage commun ──
function initials (name) {
const parts = String(name || '?').trim().split(/\s+/).filter(Boolean)
return ((parts[0]?.[0] || '') + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase() || '?'
}
function prettyDate (iso) {
if (!iso) return ''
const [y, m, d] = iso.split('-').map(Number)
return new Date(y, m - 1, d).toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
}
function barPct (v, max) { return max > 0 ? Math.max(4, Math.round((v / max) * 100)) : 0 }
const STATUS_META = {
Completed: { label: 'Complété', bg: '#dcfce7', fg: '#15803d' },
assigned: { label: 'Assigné', bg: '#dbeafe', fg: '#1d4ed8' },
in_progress: { label: 'En cours', bg: '#fef3c7', fg: '#b45309' },
open: { label: 'Ouvert', bg: '#e2e8f0', fg: '#475569' },
'On Hold': { label: 'En attente', bg: '#ffedd5', fg: '#c2410c' },
Cancelled: { label: 'Annulé', bg: '#f1f5f9', fg: '#94a3b8' },
}
function statusMeta (s) { return STATUS_META[s] || { label: s || '—', bg: '#e2e8f0', fg: '#475569' } }
function jobTime (j) {
const t = j.start_time ? String(j.start_time).slice(11, 16) : ''
return t || '—'
}
// ── Tab 1 : tournées par technicien ──
const histDate = ref(today)
const hist = ref(null)
const histLoading = ref(false)
function shiftDay (delta) {
const [y, m, d] = histDate.value.split('-').map(Number)
const nd = new Date(y, m - 1, d + delta)
const next = localDateStr(nd)
if (next > today) return
histDate.value = next
loadHistory()
}
async function loadHistory () {
histLoading.value = true
try { hist.value = await hubGet(`/dispatch/history?date=${histDate.value}`) }
catch (e) { hist.value = null; Notify.create({ type: 'negative', message: 'Chargement de l\'historique : ' + e.message }) }
finally { histLoading.value = false }
}
const histStatusSummary = computed(() => {
if (!hist.value) return []
const counts = {}
for (const t of (hist.value.techs || [])) for (const j of t.jobs) counts[j.status] = (counts[j.status] || 0) + 1
return Object.keys(counts).sort((a, b) => counts[b] - counts[a]).map(k => ({ key: k, n: counts[k], label: statusMeta(k).label.toLowerCase(), color: statusMeta(k).fg }))
})
// ── Périodes (tabs 2 & 3) ──
const rangeDays = ref(30)
const rangeFrom = computed(() => {
const d = new Date(); d.setDate(d.getDate() - (rangeDays.value - 1))
return localDateStr(d)
})
// ── Tab 2 : classement techniciens ──
const techBoard = ref([])
const techLoading = ref(false)
const techMax = computed(() => techBoard.value.reduce((m, b) => Math.max(m, b.completed), 0))
async function loadTechBoard () {
techLoading.value = true
try { const r = await hubGet(`/dispatch/leaderboard?from=${rangeFrom.value}&to=${today}`); techBoard.value = r.board || [] }
catch (e) { techBoard.value = []; Notify.create({ type: 'negative', message: 'Classement techniciens : ' + e.message }) }
finally { techLoading.value = false }
}
// ── Tab 3 : classement Inbox ──
const inboxBoard = ref([])
const inboxUnattributed = ref(0)
const inboxLoading = ref(false)
const inboxMax = computed(() => inboxBoard.value.reduce((m, b) => Math.max(m, b.replies), 0))
function agentName (email) {
if (!email) return '—'
const local = String(email).split('@')[0]
return local.split(/[.\-_]/).filter(Boolean).map(p => p[0].toUpperCase() + p.slice(1)).join(' ') || email
}
async function loadInboxBoard () {
inboxLoading.value = true
try {
const r = await hubGet(`/conversations/leaderboard?from=${rangeFrom.value}&to=${today}`)
inboxBoard.value = (r.board || []).filter(b => b.replies > 0 || b.handled > 0)
inboxUnattributed.value = r.unattributed || 0
} catch (e) { inboxBoard.value = []; Notify.create({ type: 'negative', message: 'Classement Inbox : ' + e.message }) }
finally { inboxLoading.value = false }
}
onMounted(() => { loadHistory(); loadTechBoard(); loadInboxBoard() })
</script>
<style scoped>
.hist-page { padding: 16px 20px; background: var(--ops-bg, #f8fafc); min-height: 100%; }
.hist-head { margin-bottom: 10px; }
.hist-sub { color: var(--ops-text-muted, #64748b); font-size: 13px; margin-top: 2px; }
.hist-tabs { color: var(--ops-text, #1e293b); }
.hist-panels { background: transparent; }
.hist-toolbar { display: flex; align-items: center; gap: 2px; padding: 12px 2px; flex-wrap: wrap; }
.hist-datebtn { font-weight: 600; text-transform: capitalize; }
.hist-summary { font-size: 13px; color: var(--ops-text-muted, #64748b); }
.hist-summary b { color: var(--ops-text, #1e293b); }
.hist-sumchip { margin-left: 10px; font-size: 12px; white-space: nowrap; }
.hist-loading, .hist-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 16px; color: var(--ops-text-muted, #94a3b8); text-align: center; }
.hist-empty-sub { font-size: 12px; margin-top: 6px; line-height: 1.5; }
/* Tournées */
.hist-techs { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
.hist-techcard { border-radius: 10px; background: var(--ops-surface, #fff); overflow: hidden; }
.hist-techhead { display: flex; align-items: center; padding: 10px 12px; }
.hist-techname { font-weight: 600; color: var(--ops-text, #1e293b); }
.hist-techcount { font-size: 12px; color: var(--ops-text-muted, #64748b); }
.hist-job { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-top: 1px solid var(--ops-border, #f1f5f9); font-size: 13px; }
.hist-job:first-of-type { border-top: none; }
.hist-job--cancel { opacity: 0.55; }
.hist-job--cancel .hist-jobsubj { text-decoration: line-through; }
.hist-statuschip { font-size: 11px; font-weight: 600; height: 20px; }
.hist-jobtime { font-variant-numeric: tabular-nums; color: var(--ops-text-muted, #64748b); width: 42px; flex-shrink: 0; }
.hist-jobsubj { color: var(--ops-text, #1e293b); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hist-jobdur { font-size: 12px; color: var(--ops-text-muted, #94a3b8); flex-shrink: 0; }
/* Classements */
.hist-board { max-width: 760px; }
.hist-rank { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--ops-border, #eef2f6); }
.hist-medal { width: 30px; text-align: center; font-weight: 700; font-size: 16px; color: var(--ops-text-muted, #94a3b8); flex-shrink: 0; }
.hist-medal--1, .hist-medal--2, .hist-medal--3 { font-size: 18px; }
.hist-rankname { font-weight: 600; color: var(--ops-text, #1e293b); width: 200px; max-width: 32%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hist-bar { flex: 1; height: 10px; background: var(--ops-border, #eef2f6); border-radius: 6px; overflow: hidden; margin: 0 4px; }
.hist-bar-fill { height: 100%; background: #3b82f6; border-radius: 6px; transition: width .3s; }
.hist-bar-fill--teal { background: #14b8a6; }
.hist-rankval { font-weight: 700; color: var(--ops-text, #1e293b); min-width: 38px; text-align: right; font-variant-numeric: tabular-nums; }
.hist-rankval2 { min-width: 42px; text-align: right; color: var(--ops-text-muted, #64748b); font-variant-numeric: tabular-nums; }
.hist-rankval-sub { font-size: 10px; font-weight: 400; color: var(--ops-text-muted, #94a3b8); margin-left: 2px; }
.hist-note { font-size: 12px; color: var(--ops-text-muted, #94a3b8); padding: 10px; }
</style>