gigafibre-fsm/apps/ops/src/pages/SupplierInvoicesPage.vue
louispaulb 936152d452 feat(ui): T5 — DynamicFilter sur Factures fournisseurs + File courriels
- SupplierInvoices : toggle statut → <DynamicFilter> (chips À traiter/Créées/Ignorées + recherche fournisseur/n°/expéditeur, client-side) ; presets 'filter.supplier-invoices'.
- EmailQueue : select statut → chips (Not Sent/Sending/Sent/Error, → rechargement serveur via onFilterChange) + recherche destinataire/référence (client, displayRows) ; presets 'filter.email-queue'.
Build vert, leak 0. DynamicFilter désormais sur 6 pages (Clients/Tickets/Évaluations/RDV/Factures fourn./File courriels).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:02:11 -04:00

163 lines
10 KiB
Vue

<template>
<q-page class="q-pa-md">
<div class="row items-center q-mb-sm">
<q-icon name="receipt_long" size="26px" color="primary" class="q-mr-sm" />
<div>
<div class="text-h6">Factures fournisseurs</div>
<div class="text-caption text-grey-6">Captées par OCR depuis <b>{{ invoiceTo }}</b> vérifier créer le brouillon ERPNext (jamais soumis auto).</div>
</div>
<q-space />
<q-btn flat dense icon="refresh" :loading="loading" no-caps label="Rafraîchir" @click="load" />
<q-btn unelevated dense color="primary" icon="cloud_download" :loading="polling" no-caps label="Relever maintenant" class="q-ml-sm" @click="poll" />
</div>
<q-banner v-if="!list.length && !loading" class="bg-amber-1 text-warning q-mb-md" rounded dense>
<template #avatar><q-icon name="info" color="warning" /></template>
Aucune facture captée. Vérifie que <b>cc@targointernet.com</b> est <b>membre du groupe {{ invoiceTo }}</b> (sinon rien n'arrive), puis « Relever maintenant ».
</q-banner>
<div class="row q-col-gutter-md">
<!-- Liste -->
<div :class="selected ? 'col-12 col-md-5' : 'col-12'">
<DynamicFilter v-model="filterState" dense search-placeholder="Fournisseur, n° facture, expéditeur…"
:chip-groups="chipGroups" preset-key="filter.supplier-invoices" :filtered-count="filtered.length" class="q-mb-sm" />
<div v-for="r in filtered" :key="r.name" class="si-row" :class="{ sel: selected && selected.name === r.name }" @click="selected = r">
<div class="row items-center no-wrap">
<q-icon :name="statusIcon(r.status)" :color="statusColor(r.status)" size="18px" class="q-mr-sm" />
<div class="col ellipsis">
<div class="text-weight-medium ellipsis">{{ r.vendor || '(fournisseur ?)' }}<span v-if="r.invoice_number" class="text-grey-6"> · {{ r.invoice_number }}</span></div>
<div class="text-caption text-grey-6">{{ r.invoice_date || r.email_date || '' }} · {{ r.email_from }}</div>
</div>
<div class="text-right">
<div class="text-weight-bold">{{ fmtMoney(r.total, r.currency) }}</div>
<q-badge :color="statusBadge(r.status)" :label="statusLabel(r.status)" />
</div>
</div>
</div>
<div v-if="!filtered.length && list.length" class="text-caption text-grey-5 q-pa-sm">Rien dans ce filtre.</div>
</div>
<!-- Détail -->
<div v-if="selected" class="col-12 col-md-7">
<div class="ops-card">
<div class="row items-center q-mb-sm">
<div class="text-subtitle1 text-weight-bold">{{ selected.vendor || '(fournisseur à confirmer)' }}</div>
<q-space />
<q-btn flat round dense icon="open_in_new" :href="erpBase + '/app/supplier-invoice-intake/' + encodeURIComponent(selected.name)" target="_blank"><q-tooltip>Ouvrir l'intake dans ERPNext</q-tooltip></q-btn>
<q-btn flat round dense icon="close" @click="selected = null" />
</div>
<div class="row q-col-gutter-sm q-mb-sm">
<div class="col-6 col-sm-3"><div class="si-lbl">Total</div><div class="text-weight-bold">{{ fmtMoney(selected.total, selected.currency) }}</div></div>
<div class="col-6 col-sm-3"><div class="si-lbl">Sous-total</div><div>{{ fmtMoney(selected.subtotal, selected.currency) }}</div></div>
<div class="col-6 col-sm-3"><div class="si-lbl">TPS</div><div>{{ fmtMoney(selected.tax_gst, selected.currency) }}</div></div>
<div class="col-6 col-sm-3"><div class="si-lbl">TVQ</div><div>{{ fmtMoney(selected.tax_qst, selected.currency) }}</div></div>
<div class="col-6 col-sm-4"><div class="si-lbl">N° facture</div><div>{{ selected.invoice_number || '—' }}</div></div>
<div class="col-6 col-sm-4"><div class="si-lbl">Date</div><div>{{ selected.invoice_date || '—' }}</div></div>
<div class="col-6 col-sm-4"><div class="si-lbl">Échéance</div><div>{{ selected.due_date || '—' }}</div></div>
</div>
<q-banner v-if="selected.ocr_error" class="bg-red-1 text-negative q-mb-sm" dense rounded><q-icon name="error" /> OCR : {{ selected.ocr_error }}</q-banner>
<!-- Aperçu pièce jointe -->
<div class="att-box q-mb-sm">
<iframe v-if="isPdf(selected)" :src="attUrl(selected)" class="att-frame" />
<img v-else :src="attUrl(selected)" class="att-img" @error="imgError = true" />
<div v-if="imgError" class="text-caption text-grey-6 q-pa-sm">Aperçu indisponible — <a :href="attUrl(selected)" target="_blank">ouvrir la pièce</a></div>
</div>
<!-- Lien PI déjà créé -->
<q-banner v-if="selected.purchase_invoice" class="bg-green-1 text-green-9 q-mb-sm" dense rounded>
<q-icon name="check_circle" /> Brouillon créé : <b>{{ selected.purchase_invoice }}</b>
<template #action><q-btn flat dense color="green-9" icon="open_in_new" label="Ouvrir dans ERPNext" no-caps :href="erpBase + '/app/purchase-invoice/' + encodeURIComponent(selected.purchase_invoice)" target="_blank" /></template>
</q-banner>
<div class="row items-center q-gutter-sm">
<q-btn v-if="selected.status !== 'Created'" unelevated color="primary" icon="post_add" label="Créer le brouillon ERPNext" no-caps :loading="creating" @click="createDraft" />
<q-btn v-if="selected.status !== 'Ignored'" flat color="grey-7" icon="block" label="Ignorer" no-caps @click="ignore" />
<q-space />
<span class="text-caption text-grey-5">ERPNext = registre. Vérifie le fournisseur avant de soumettre.</span>
</div>
</div>
</div>
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
const $q = useQuasar()
const list = ref([])
const loading = ref(false)
const polling = ref(false)
const creating = ref(false)
const selected = ref(null)
// Filtre dynamique (client) : recherche fournisseur/n°/expéditeur + chips statut + presets.
const filterState = ref({ search: '', chips: { statut: 'open' } })
const chipGroups = [{ key: 'statut', label: 'Statut', icon: 'label', options: [{ value: 'open', label: 'À traiter' }, { value: 'Created', label: 'Créées' }, { value: 'Ignored', label: 'Ignorées' }] }]
const invoiceTo = ref('factures@targointernet.com')
const erpBase = ref('https://erp.gigafibre.ca')
const imgError = ref(false)
watch(selected, () => { imgError.value = false })
const filtered = computed(() => {
const st = filterState.value.chips.statut || 'all'
let arr = st === 'all' ? list.value : (st === 'open' ? list.value.filter(r => r.status === 'New' || r.status === 'Error') : list.value.filter(r => r.status === st))
const q = (filterState.value.search || '').toLowerCase().trim()
if (q) arr = arr.filter(r => (String(r.vendor || '') + ' ' + String(r.invoice_number || '') + ' ' + String(r.email_from || '')).toLowerCase().includes(q))
return arr
})
function fmtMoney (v, cur) { if (v == null || v === '') return '—'; return Number(v).toLocaleString('fr-CA', { style: 'currency', currency: cur || 'CAD' }) }
function isPdf (r) { return /\.pdf$/i.test(r.attachment_filename || '') || /\.pdf$/i.test(r.attachment || '') }
function attUrl (r) { return `${HUB_URL}/supplier-invoices/${encodeURIComponent(r.name)}/attachment` }
function statusLabel (s) { return ({ New: 'À traiter', Created: 'Créée', Ignored: 'Ignorée', Error: 'Erreur' }[s] || s) }
function statusBadge (s) { return ({ New: 'blue-6', Created: 'green-6', Ignored: 'grey-5', Error: 'red-6' }[s] || 'grey-6') }
function statusColor (s) { return ({ New: 'blue-6', Created: 'green-7', Ignored: 'grey-5', Error: 'red-6' }[s] || 'grey-6') }
function statusIcon (s) { return ({ New: 'fiber_new', Created: 'check_circle', Ignored: 'block', Error: 'error' }[s] || 'receipt') }
async function load () {
loading.value = true
try {
const r = await fetch(`${HUB_URL}/supplier-invoices`)
if (r.ok) { const d = await r.json(); list.value = d.invoices || []; invoiceTo.value = d.invoice_to || invoiceTo.value; if (d.erp_base) erpBase.value = d.erp_base; if (selected.value) selected.value = list.value.find(x => x.name === selected.value.name) || null }
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { loading.value = false }
}
async function poll () {
polling.value = true
try { const r = await fetch(`${HUB_URL}/supplier-invoices/poll`, { method: 'POST' }); const d = await r.json(); $q.notify({ type: d.ok ? 'positive' : 'negative', message: d.ok ? `Relève : ${d.added || 0} facture(s) captée(s)` : (d.error || 'échec') }); await load() } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { polling.value = false }
}
async function createDraft () {
if (!selected.value) return
creating.value = true
try {
const r = await fetch(`${HUB_URL}/supplier-invoices/${encodeURIComponent(selected.value.name)}/create`, { method: 'POST' })
const d = await r.json()
if (d.ok) { $q.notify({ type: 'positive', message: `Brouillon créé : ${d.name}${d.supplier_matched ? '' : ' (fournisseur à confirmer)'}` }) }
else $q.notify({ type: 'negative', message: 'Échec : ' + (d.error || '') + '. Crée-la manuellement dans ERPNext.', timeout: 6000 })
await load()
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { creating.value = false }
}
async function ignore () {
if (!selected.value) return
try { await fetch(`${HUB_URL}/supplier-invoices/${encodeURIComponent(selected.value.name)}/ignore`, { method: 'POST' }); $q.notify({ type: 'info', message: 'Ignorée' }); await load() } catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
onMounted(load)
</script>
<style scoped>
.si-row { border: 1px solid #e2e8f0; border-radius: 8px; padding: 9px 11px; margin-bottom: 6px; cursor: pointer; transition: all .1s; }
.si-row:hover { border-color: #a5b4fc; background: #f8faff; }
.si-row.sel { border-color: #6366f1; background: #eef2ff; }
.si-lbl { font-size: .7rem; color: #94a3b8; text-transform: uppercase; }
.att-box { border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background: #f8fafc; }
.att-frame { width: 100%; height: 460px; border: 0; background: #fff; display: block; }
.att-img { width: 100%; max-height: 460px; object-fit: contain; display: block; background: #fff; }
</style>