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>
89 lines
5.0 KiB
Vue
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>
|