gigafibre-fsm/apps/ops/src/components/shared/OutboxPanel.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

89 lines
5.0 KiB
Vue

<template>
<!-- Tampon d'envoi : courriels multi-destinataires retenus N s avant l'envoi on voit + on peut annuler -->
<transition name="ob-slide">
<div v-if="pending.length" class="outbox-panel">
<div class="ob-head row items-center no-wrap">
<q-icon name="schedule_send" color="amber-9" size="20px" class="q-mr-xs" />
<div class="text-weight-bold">{{ pending.length }} courriel{{ pending.length > 1 ? 's' : '' }} en attente d'envoi</div>
<q-space />
<q-btn dense flat no-caps size="sm" color="red-7" icon="block" label="Tout annuler" @click="cancelAll" />
</div>
<div v-for="it in pending" :key="it.id" class="ob-item row items-center no-wrap"
:class="{ 'ob-failed': it.status === 'failed' }">
<q-circular-progress v-if="it.status !== 'failed'" :value="pct(it)" size="38px" :thickness="0.18"
color="amber-8" track-color="amber-2" show-value class="q-mr-sm">
<span class="text-caption text-weight-bold">{{ secLeft(it) }}</span>
</q-circular-progress>
<q-icon v-else name="error" color="red-7" size="30px" class="q-mr-sm" />
<div class="col" style="min-width:0">
<div class="text-caption text-grey-7 ellipsis">
<q-badge :color="kindColor(it.kind)" text-color="white" :label="it.label || it.kind" class="q-mr-xs" />
{{ it.count }} destinataire{{ it.count > 1 ? 's' : '' }}
</div>
<div class="text-body2 ellipsis" :title="it.subject">{{ it.subject || '(sans objet)' }}</div>
<div class="text-caption text-grey-6 ellipsis" :title="it.recipients.join(', ')">{{ it.recipients.join(', ') }}</div>
<div v-if="it.status === 'failed'" class="text-caption text-red-7 ellipsis">Échec : {{ it.error }}</div>
</div>
<q-btn dense flat round icon="close" color="grey-7" @click="cancel(it.id)"><q-tooltip>Annuler (ne pas envoyer)</q-tooltip></q-btn>
<q-btn dense flat round :icon="it.status === 'failed' ? 'refresh' : 'send'" color="green-7" @click="sendNow(it.id)">
<q-tooltip>{{ it.status === 'failed' ? 'Réessayer' : 'Envoyer maintenant' }}</q-tooltip>
</q-btn>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { useSSE } from 'src/composables/useSSE'
const $q = useQuasar()
const pending = ref([])
const holdSec = ref(20)
const now = ref(Date.now())
let ticker = null
let tick = 0
const sse = useSSE({ listeners: { outbox: (d) => { pending.value = d.pending || []; if (d.holdSec) holdSec.value = d.holdSec } } })
async function refresh () {
try { const r = await fetch(`${HUB_URL}/outbox`); if (r.ok) { const d = await r.json(); pending.value = d.pending || []; if (d.holdSec) holdSec.value = d.holdSec } } catch (e) { /* */ }
}
function secLeft (it) { return Math.max(0, Math.ceil((it.sendAt - now.value) / 1000)) }
function pct (it) { const total = holdSec.value * 1000; const left = Math.max(0, it.sendAt - now.value); return Math.max(0, Math.min(100, (left / total) * 100)) }
function kindColor (k) { return k === 'queue-notif' ? 'indigo-5' : k === 'ticket-notif' ? 'teal-6' : 'blue-grey-5' }
async function cancel (id) {
pending.value = pending.value.filter(x => x.id !== id) // optimiste
try { await fetch(`${HUB_URL}/outbox/cancel`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }) } catch (e) { /* */ }
}
async function cancelAll () {
const n = pending.value.length; pending.value = []
try { await fetch(`${HUB_URL}/outbox/cancel-all`, { method: 'POST' }); $q.notify({ type: 'info', message: `${n} envoi(s) annulé(s)` }) } catch (e) { /* */ }
}
async function sendNow (id) {
try { const r = await fetch(`${HUB_URL}/outbox/send-now`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }); const d = await r.json(); if (!d.ok) $q.notify({ type: 'negative', message: d.error || 'Échec envoi' }) } catch (e) { /* */ }
refresh()
}
onMounted(() => {
refresh()
sse.connect(['outbox'])
ticker = setInterval(() => { now.value = Date.now(); if (++tick % 6 === 0) refresh() }, 1000) // countdown + resync léger toutes les 6 s
})
onUnmounted(() => { if (ticker) clearInterval(ticker); sse.disconnect() })
</script>
<style scoped>
.outbox-panel { position: fixed; left: 18px; bottom: 18px; z-index: 4000; width: 390px; max-width: calc(100vw - 36px);
background: #fffdf5; border: 1px solid #fde68a; border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.18); overflow: hidden; }
.ob-head { padding: 8px 10px; background: #fef3c7; color: #92400e; border-bottom: 1px solid #fde68a; }
.ob-item { padding: 8px 10px; border-bottom: 1px solid #f5f0e0; gap: 2px; }
.ob-item:last-child { border-bottom: none; }
.ob-failed { background: #fef2f2; }
.ob-slide-enter-active, .ob-slide-leave-active { transition: all .25s ease; }
.ob-slide-enter-from, .ob-slide-leave-to { transform: translateY(20px); opacity: 0; }
</style>