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>
270 lines
15 KiB
Vue
270 lines
15 KiB
Vue
<template>
|
|
<q-page class="hist-page">
|
|
<div class="hist-head">
|
|
<div class="text-h6">Historique & 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 & 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>
|