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>
158 lines
8.4 KiB
Vue
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>
|