gigafibre-fsm/apps/ops/src/pages/EmailQueuePage.vue
louispaulb 7f8382496e feat(ops): Email Queue admin page (view/delete/purge ERPNext outbound)
Surfaces ERPNext's Email Queue in Ops (nav « File courriels ») so ops can see
what's queued — important now that mute_emails=1 + scheduler paused mean nothing
flushes — and delete/purge stale entries without the ERPNext desk.

- hub lib/email-queue.js: GET list (by status, recipients read from each row's
  full doc since ERPNext ignores fields on child-doctype REST), DELETE :name,
  POST /purge {status}. Wired in server.js.
- ops: api/emailQueue.js + EmailQueuePage.vue (status filter, recipients,
  reference, error tooltip, per-row delete + « Purger Not Sent »), route + nav.

Verified live: 13 'Not Sent' (old internal test emails, no invoice refs).

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

149 lines
5.7 KiB
Vue

<template>
<q-page padding>
<div class="row items-center q-mb-md">
<div class="text-h6 text-weight-bold">File de courriels (ERPNext)</div>
<q-space />
<q-select v-model="status" :options="statusOptions" emit-value map-options dense outlined
label="Statut" style="width:160px" class="q-mr-sm" @update:model-value="load" />
<q-btn flat dense icon="refresh" :loading="loading" @click="load" class="q-mr-sm" />
<q-btn color="negative" outline dense icon="delete_sweep" label="Purger « Not Sent »"
:disable="loading || !notSentCount" @click="confirmPurge" />
</div>
<q-banner dense rounded class="bg-blue-1 text-blue-9 q-mb-md">
<template #avatar><q-icon name="mark_email_read" /></template>
ERPNext est en <strong>mute_emails</strong> et le scheduler est en pause : <strong>rien ne s'envoie</strong>.
Ces entrées sont seulement en file. Tu peux les inspecter et les supprimer en toute sécurité.
</q-banner>
<div class="row q-gutter-sm q-mb-md">
<q-chip v-for="(n, s) in byStatus" :key="s" :color="statusColor(s)" text-color="white" dense>
{{ s }} : {{ n }}
</q-chip>
<q-chip v-if="!rows.length && loaded" color="grey-4" text-color="grey-8" dense>File vide</q-chip>
</div>
<q-table
v-if="loaded"
:rows="rows" :columns="columns" row-key="name"
flat bordered dense class="ops-table"
:pagination="{ rowsPerPage: 50, sortBy: 'creation', descending: true }"
:loading="loading"
>
<template #body-cell-status="props">
<q-td :props="props">
<q-chip :color="statusColor(props.value)" text-color="white" dense size="sm" :label="props.value" />
</q-td>
</template>
<template #body-cell-recipients="props">
<q-td :props="props">
<div v-for="(r, i) in (props.row.recipients || [])" :key="i" class="text-caption">{{ r }}</div>
<span v-if="!(props.row.recipients || []).length" class="text-grey-5">—</span>
</q-td>
</template>
<template #body-cell-reference="props">
<q-td :props="props">
<span v-if="props.row.reference_doctype" class="text-caption">
{{ props.row.reference_doctype }} · {{ props.row.reference_name }}
</span>
<span v-else class="text-grey-5">—</span>
</q-td>
</template>
<template #body-cell-creation="props">
<q-td :props="props">{{ fmtDate(props.value) }}</q-td>
</template>
<template #body-cell-error="props">
<q-td :props="props">
<q-icon v-if="props.value" name="error_outline" color="negative" />
<q-tooltip v-if="props.value" max-width="480px" class="text-body2">{{ props.value }}</q-tooltip>
<span v-else class="text-grey-5">—</span>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn dense flat round size="sm" icon="delete" color="negative" @click="confirmDelete(props.row)">
<q-tooltip>Supprimer ce courriel de la file</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { listEmailQueue, deleteEmailQueueItem, purgeEmailQueue } from 'src/api/emailQueue'
const $q = useQuasar()
const rows = ref([])
const loading = ref(false)
const loaded = ref(false)
const status = ref('')
const byStatus = ref({})
const statusOptions = [
{ label: 'Tous', value: '' },
{ label: 'Not Sent', value: 'Not Sent' },
{ label: 'Sending', value: 'Sending' },
{ label: 'Sent', value: 'Sent' },
{ label: 'Error', value: 'Error' },
]
const columns = [
{ name: 'status', label: 'Statut', field: 'status', align: 'left', sortable: true },
{ name: 'recipients', label: 'Destinataire(s)', field: 'recipients', align: 'left' },
{ name: 'reference', label: 'Référence', field: 'reference_doctype', align: 'left' },
{ name: 'creation', label: 'Créé', field: 'creation', align: 'left', sortable: true },
{ name: 'error', label: 'Erreur', field: 'error', align: 'center' },
{ name: 'actions', label: '', field: 'name', align: 'center' },
]
const notSentCount = computed(() => byStatus.value['Not Sent'] || 0)
function statusColor (s) {
return { 'Not Sent': 'orange-8', Sending: 'blue-6', Sent: 'green-7', Error: 'negative', Expired: 'grey-6' }[s] || 'grey-7'
}
function fmtDate (iso) {
return iso ? new Date(iso.replace(' ', 'T')).toLocaleString('fr-CA', { dateStyle: 'medium', timeStyle: 'short' }) : ''
}
async function load () {
loading.value = true
try {
const data = await listEmailQueue(status.value)
rows.value = data.rows || []
byStatus.value = data.by_status || {}
loaded.value = true
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur : ' + e.message })
} finally {
loading.value = false
}
}
function confirmDelete (row) {
$q.dialog({
title: 'Supprimer',
message: `Supprimer ce courriel de la file (${(row.recipients || []).join(', ') || row.name}) ?`,
cancel: true, persistent: true,
}).onOk(async () => {
try { await deleteEmailQueueItem(row.name); $q.notify({ type: 'positive', message: 'Supprimé' }); load() }
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
})
}
function confirmPurge () {
$q.dialog({
title: 'Purger',
message: `Supprimer les ${notSentCount.value} courriel(s) « Not Sent » de la file ?`,
cancel: true, persistent: true,
}).onOk(async () => {
try { const r = await purgeEmailQueue('Not Sent'); $q.notify({ type: 'positive', message: `${r.deleted} supprimé(s)` }); load() }
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
})
}
onMounted(load)
</script>