gigafibre-fsm/apps/ops/src/components/shared/DepartmentBoard.vue
louispaulb 0f65c02d83 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
Accumulated work on the dispatch/legacy-writeback branch:
- Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard,
  PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs;
  hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js.
- Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js,
  sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental
  migration scripts; LegacySyncPage, SupplierInvoices + negative-billing /
  terminated-active reports.
- Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit,
  olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers,
  legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets,
  ClientDetail updates.
- docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__.

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

158 lines
8.4 KiB
Vue

<template>
<div class="dept-wrap">
<div class="dept-head">
<div class="text-caption text-grey-7">{{ totalActive }} active(s) · glisser une carte = classer dans une file · clic = ouvrir le fil</div>
<q-space />
<q-toggle v-model="showResolved" dense size="sm" label="Inclure résolues" color="grey-7" class="q-mr-sm" />
<q-btn flat dense round icon="refresh" :loading="loading" @click="fetchList" />
</div>
<div class="dept-board">
<div v-for="dep in depts" :key="dep" class="dept-col" :class="{ 'drop-hover': dropDept === dep }"
@dragover.prevent="dropDept = dep" @dragleave="dropDept === dep && (dropDept = null)" @drop="onDrop(dep)">
<div class="dept-col-hd" :class="dep === UNASSIGNED ? 'dh-unassigned' : 'dh-' + slug(dep)">
<q-icon :name="deptIcon(dep)" size="16px" class="q-mr-xs" />
<span class="ellipsis col">{{ dep }}</span>
<q-badge color="white" text-color="grey-9" class="q-ml-xs">{{ (columns[dep] || []).length }}</q-badge>
<q-btn flat dense round icon="add" size="xs" color="white" class="q-ml-xs">
<q-tooltip>Composer dans « {{ dep }} »</q-tooltip>
<q-menu auto-close>
<q-list dense style="min-width:160px">
<q-item clickable @click="compose('email', dep)"><q-item-section avatar><q-icon name="mail" color="red-5" /></q-item-section><q-item-section>Nouveau courriel</q-item-section></q-item>
<q-item clickable @click="compose('sms', dep)"><q-item-section avatar><q-icon name="sms" color="teal-6" /></q-item-section><q-item-section>Nouveau texto</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<div class="dept-col-body">
<div v-for="d in (columns[dep] || [])" :key="d.id" class="dept-card" :class="{ 'needs-reply': needsReply(d) }"
draggable="true" @dragstart="onDragStart(d)" @dragend="dragCard = null" @click="openCard(d)">
<div class="dept-card-t">
<q-icon :name="d.channel === 'email' ? 'mail' : 'sms'" size="13px" class="q-mr-xs" :color="d.channel === 'email' ? 'red-5' : 'teal-6'" />
<span class="ellipsis col">{{ d.customerName || d.email || d.phone || 'Inconnu' }}</span>
<q-badge v-if="d.status === 'active'" color="green" class="q-ml-xs" style="font-size:.55rem;padding:1px 4px">actif</q-badge>
</div>
<div class="dept-card-s ellipsis">{{ preview(d) }}</div>
<div class="dept-card-m">
<span class="text-grey-6">{{ relTime(d.lastMessage && d.lastMessage.ts) }}</span>
<q-icon v-if="needsReply(d)" name="reply" size="12px" color="blue-7" class="q-ml-xs"><q-tooltip>En attente de réponse</q-tooltip></q-icon>
<q-space />
<span v-if="d.assignee" class="dept-assignee"><q-icon name="pan_tool" size="11px" /> {{ shortAgent(d.assignee) }}</span>
</div>
</div>
<div v-if="!(columns[dep] || []).length" class="dept-empty"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { useConversations } from 'src/composables/useConversations'
import { shortAgent, relTime } from 'src/composables/useFormatters'
const $q = useQuasar()
const {
discussions, QUEUES, assignQueue, fetchList, loading,
openDiscussion, panelOpen, newDialogOpen, newDialogChannel,
} = useConversations()
const UNASSIGNED = 'Non assigné'
const depts = computed(() => [...QUEUES, UNASSIGNED])
const showResolved = ref(false)
const dragCard = ref(null)
const dropDept = ref(null)
const DEPT_ICONS = {
'Facturations': 'request_quote',
'Service à la clientèle': 'support_agent',
'Support': 'build',
'Supports': 'build',
'Technicien': 'engineering',
}
function deptIcon (dep) { return dep === UNASSIGNED ? 'inbox' : (DEPT_ICONS[dep] || 'groups') }
function slug (s) { return String(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z]+/g, '-').replace(/^-|-$/g, '') }
function needsReply (d) { return d.status === 'active' && d.lastMessage && d.lastMessage.from === 'customer' }
function preview (d) { return d.subject || (d.lastMessage && d.lastMessage.text) || '—' }
function tsOf (d) { const t = d.lastMessage && d.lastMessage.ts; const n = t ? new Date(t).getTime() : 0; return Number.isFinite(n) ? n : 0 }
// shortAgent + relTime (ex-fmtTime, logique identique) viennent de useFormatters (consolidation)
// Regroupe les discussions unifiées (SMS + courriel) par file/département.
const columns = computed(() => {
const map = {}
for (const dep of depts.value) map[dep] = []
for (const d of (discussions.value || [])) {
if (!showResolved.value && d.status === 'closed') continue
const q = (d.queue && QUEUES.includes(d.queue)) ? d.queue : UNASSIGNED
map[q].push(d)
}
for (const dep of depts.value) map[dep].sort((a, b) => tsOf(b) - tsOf(a))
return map
})
const totalActive = computed(() => (discussions.value || []).filter(d => d.status === 'active').length)
// Jeton de conversation à classer (active sinon dernière sinon token de la discussion).
function discToken (d) {
const active = (d.conversations || []).find(c => c.status === 'active')
return (active && active.token) || d.token || (((d.conversations || []).slice(-1)[0]) || {}).token || null
}
function onDragStart (d) { dragCard.value = d }
async function onDrop (dep) {
dropDept.value = null
const d = dragCard.value; dragCard.value = null
if (!d) return
const tok = discToken(d)
if (!tok) { $q.notify({ type: 'warning', message: 'Conversation sans jeton — impossible de classer.' }); return }
const newQueue = dep === UNASSIGNED ? '' : dep
if ((d.queue || '') === newQueue) return
const prev = d.queue
d.queue = newQueue // déplacement optimiste (assignQueue rafraîchit ensuite la liste)
try {
await assignQueue(tok, newQueue)
$q.notify({ type: 'positive', message: `${d.customerName || d.phone || 'Conversation'}${dep}`, timeout: 1200 })
} catch (e) { d.queue = prev; $q.notify({ type: 'negative', message: 'Échec du classement' }) }
}
function openCard (d) { panelOpen.value = true; openDiscussion(d) }
// Envoi depuis une colonne : ouvre le dialogue partagé (rendu dans ConversationPanel, hors du tiroir).
// v1 : la nouvelle conversation atterrit en « Non assigné » → l'agent la glisse dans la bonne file (auto-routage à venir).
function compose (channel /* , dep */) {
newDialogChannel.value = channel
newDialogOpen.value = true
}
onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList() })
</script>
<style scoped>
.dept-wrap { padding: 10px 14px; }
.dept-head { display: flex; align-items: center; margin-bottom: 10px; }
.dept-board { display: flex; gap: 12px; overflow-x: auto; align-items: flex-start; padding-bottom: 8px; }
.dept-col { flex: 0 0 270px; background: #f1f5f9; border-radius: 10px; display: flex; flex-direction: column; max-height: calc(100vh - 230px); }
.dept-col.drop-hover { outline: 2px dashed #6366f1; outline-offset: -2px; }
.dept-col-hd { display: flex; align-items: center; font-weight: 700; font-size: .8rem; color: #fff; padding: 7px 10px; border-radius: 10px 10px 0 0; }
.dh-facturations { background: #d97706; }
.dh-service-a-la-clientele { background: #6366f1; }
.dh-support, .dh-supports { background: #0891b2; }
.dh-technicien { background: #16a34a; }
.dh-unassigned { background: #64748b; }
.dept-col-body { padding: 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; }
.dept-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.dept-card:hover { border-color: #6366f1; }
.dept-card.needs-reply { border-left: 3px solid #2563eb; background: #eff6ff; }
.dept-card-t { font-weight: 600; font-size: .82rem; display: flex; align-items: center; }
.dept-card-s { font-size: .75rem; color: #475569; margin-top: 2px; }
.dept-card-m { display: flex; align-items: center; font-size: .68rem; margin-top: 5px; }
.dept-assignee { color: #b45309; font-weight: 600; }
.dept-empty { text-align: center; color: #cbd5e1; padding: 10px; font-size: .8rem; }
.col { min-width: 0; }
@media (max-width: 640px) {
.dept-board { scroll-snap-type: x mandatory; }
.dept-col { flex-basis: 86vw; scroll-snap-align: start; max-height: calc(100vh - 200px); }
}
</style>