gigafibre-fsm/apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

393 lines
17 KiB
Vue

<template>
<!-- View toggle -->
<q-tabs v-model="viewMode" dense align="left" inline-label no-caps
indicator-color="primary" active-color="primary" class="q-mb-sm"
style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<q-tab name="fields" icon="list_alt" label="Détails" />
<q-tab name="client" icon="receipt_long" label="Aperçu client" />
</q-tabs>
<!-- ══════════ CLIENT PREVIEW ══════════ -->
<div v-if="viewMode === 'client'" class="q-mb-md">
<div class="row q-gutter-sm q-mb-sm q-pa-sm" style="background:#f8fafc;border-radius:8px">
<q-btn outline dense no-caps color="negative" icon="picture_as_pdf" label="Télécharger PDF"
size="sm" :href="pdfDownloadUrl" target="_blank" />
<q-btn outline dense no-caps color="primary" icon="content_copy" label="Copier lien paiement"
size="sm" :loading="generatingPayLink" @click="copyPayLink" />
</div>
<iframe :src="clientPreviewUrl" class="invoice-client-preview" />
</div>
<!-- ══════════ OPS FIELDS (default) ══════════ -->
<template v-else>
<!-- Action bar -->
<div class="row q-gutter-sm q-mb-md q-pa-sm" style="background:#f8fafc;border-radius:8px">
<!-- Draft: Submit -->
<q-btn v-if="isDraft" outline dense no-caps color="green-7" icon="check_circle" label="Soumettre"
size="sm" :loading="actionLoading" @click="submitInvoice">
<q-tooltip>Valider et soumettre cette facture</q-tooltip>
</q-btn>
<!-- Draft: Delete -->
<q-btn v-if="isDraft" outline dense no-caps color="negative" icon="delete" label="Supprimer"
size="sm" :loading="actionLoading" @click="deleteInvoice">
<q-tooltip>Supprimer ce brouillon</q-tooltip>
</q-btn>
<!-- Submitted: Cancel -->
<q-btn v-if="isSubmitted && !doc.is_return" outline dense no-caps color="warning" icon="cancel" label="Annuler"
size="sm" :loading="actionLoading" @click="cancelInvoice">
<q-tooltip>Annuler cette facture</q-tooltip>
</q-btn>
<!-- Submitted: Credit Note -->
<q-btn v-if="isSubmitted && !doc.is_return" outline dense no-caps color="negative" icon="replay" label="Note de credit"
size="sm" :loading="actionLoading" @click="createCreditNote">
<q-tooltip>Creer une note de credit (renversement)</q-tooltip>
</q-btn>
<!-- Submitted unpaid: Send payment link -->
<q-btn v-if="isUnpaid" outline dense no-caps color="primary" icon="send" label="Lien paiement"
size="sm" :loading="sendingLink" @click="sendPaymentLink">
<q-tooltip>Envoyer un lien de paiement par SMS et courriel</q-tooltip>
</q-btn>
<!-- Submitted unpaid: Charge saved card -->
<q-btn v-if="isUnpaid" outline dense no-caps color="positive" icon="credit_card" label="Charger carte"
size="sm" :loading="chargingCard" @click="chargeSavedCard">
<q-tooltip>Debiter la carte enregistree du client</q-tooltip>
</q-btn>
<!-- PDF -->
<q-btn outline dense no-caps color="negative" icon="picture_as_pdf" label="PDF"
size="sm" @click="$emit('open-pdf', docName)" />
</div>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Date</span>{{ doc.posting_date }}</div>
<div class="mf"><span class="mf-label">Echeance</span>{{ doc.due_date || '---' }}</div>
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="invStatusClass(doc.status)">{{ localStatus || doc.status }}</span></div>
<div class="mf"><span class="mf-label">Total HT</span>{{ formatMoney(doc.net_total) }}</div>
<div class="mf"><span class="mf-label">Taxes</span>{{ formatMoney(doc.total_taxes_and_charges) }}</div>
<div class="mf"><span class="mf-label">Total TTC</span><strong>{{ formatMoney(doc.grand_total) }}</strong></div>
<div class="mf"><span class="mf-label">Solde du</span><span :class="{'text-red': doc.outstanding_amount > 0}">{{ formatMoney(doc.outstanding_amount) }}</span></div>
<div class="mf"><span class="mf-label">Devise</span>{{ doc.currency || 'CAD' }}</div>
<div class="mf" v-if="doc.is_return"><span class="mf-label">Type</span><span class="text-red text-weight-medium">Note de credit</span></div>
<div class="mf" v-if="doc.return_against"><span class="mf-label">Renversement de</span><a class="text-primary cursor-pointer" @click="$emit('navigate', 'Sales Invoice', doc.return_against, 'Facture ' + doc.return_against)">{{ doc.return_against }}</a></div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Remarques</div>
<InlineField :value="doc.remarks === 'No Remarks' ? '' : doc.remarks" field="remarks" doctype="Sales Invoice" :docname="docName"
type="textarea" placeholder="Ajouter des remarques..." @saved="v => doc.remarks = v.value" />
</div>
<div v-if="doc.items?.length" class="q-mt-md">
<div class="info-block-title">Articles ({{ doc.items.length }})</div>
<DataTable
:rows="doc.items" :columns="invItemCols" row-key="idx"
flat dense class="ops-table" hide-pagination :pagination="{ rowsPerPage: 0 }"
>
<template #body-cell-amount="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.amount) }}</q-td>
</template>
<template #body-cell-rate="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.rate) }}</q-td>
</template>
</DataTable>
</div>
<div v-if="doc.taxes?.length" class="q-mt-md">
<div class="info-block-title">Taxes</div>
<div v-for="t in doc.taxes" :key="t.idx" class="info-row q-py-xs">
<span>{{ t.description || t.account_head }}</span>
<q-space />
<span>{{ formatMoney(t.tax_amount) }}</span>
</div>
</div>
<div v-if="comments.length" class="q-mt-md">
<div class="info-block-title">Notes ({{ comments.length }})</div>
<div v-for="mc in comments" :key="mc.name" class="q-py-xs" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="text-caption text-grey-6">{{ mc.comment_by || 'Systeme' }} &middot; {{ formatDateShort(mc.creation) }}</div>
<div class="text-body2" style="white-space:pre-line">{{ mc.content }}</div>
</div>
</div>
</template>
</template>
<style scoped>
.invoice-client-preview {
width: 100%;
min-height: 75vh;
border: 1px solid var(--ops-border, #e2e8f0);
border-radius: 8px;
background: #fff;
}
/* ── Field grid (duplicated from DetailModal.vue — parent scoped styles
do not cascade into slot component children in Vue SFC). ── */
.modal-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; }
.mf { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; font-size: 0.875rem; border-bottom: 1px solid #f1f5f9; }
.mf-label { font-size: 0.72rem; font-weight: 600; color: #6b7280; min-width: 80px; flex-shrink: 0; text-transform: uppercase; letter-spacing: 0.03em; }
.info-block-title { font-size: 0.72rem; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; border-bottom: 1px solid var(--ops-border, #e2e8f0); padding-bottom: 4px; }
.info-row { display: flex; align-items: center; gap: 8px; padding: 2px 0; }
.ops-badge {
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 0.7rem;
font-weight: 600; background: #eef2ff; color: #4338ca;
}
</style>
<script setup>
import { ref, computed } from 'vue'
import DataTable from 'src/components/shared/DataTable.vue'
import { Notify, useQuasar } from 'quasar'
import { formatMoney, formatDateShort } from 'src/composables/useFormatters'
import { invStatusClass } from 'src/composables/useStatusClasses'
import { invItemCols } from 'src/config/table-columns'
import { authFetch } from 'src/api/auth'
import { deleteDoc, getDoc, updateDoc } from 'src/api/erp'
import { BASE_URL } from 'src/config/erpnext'
import InlineField from 'src/components/shared/InlineField.vue'
const HUB_URL = location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
const props = defineProps({
doc: { type: Object, required: true },
docName: String,
comments: { type: Array, default: () => [] },
})
const emit = defineEmits(['navigate', 'open-pdf', 'deleted'])
const $q = useQuasar()
const actionLoading = ref(false)
const localStatus = ref(null)
const sendingLink = ref(false)
const chargingCard = ref(false)
const viewMode = ref('fields')
const generatingPayLink = ref(false)
// Client preview = the actual Chromium-rendered PDF (same pipeline the client gets
// via email / QR). Using our whitelisted invoice_pdf endpoint — NOT Frappe's
// /printview (which wraps the template in printview.html and inflates line-heights).
const pdfDownloadUrl = computed(() =>
`${BASE_URL}/api/method/gigafibre_utils.api.invoice_pdf?name=${encodeURIComponent(props.docName)}`,
)
// #toolbar=0 hides the PDF viewer chrome for a cleaner in-panel look.
const clientPreviewUrl = computed(() => `${pdfDownloadUrl.value}#toolbar=0&navpanes=0&scrollbar=1`)
async function copyPayLink () {
generatingPayLink.value = true
try {
const res = await authFetch(
`${BASE_URL}/api/method/gigafibre_utils.api.pay_token?invoice=${encodeURIComponent(props.docName)}&ttl_days=30`,
)
const data = await res.json()
const url = data?.message?.url
if (!url) throw new Error('Pas d\'URL retournée')
await navigator.clipboard.writeText(url)
Notify.create({ type: 'positive', message: 'Lien de paiement copié', position: 'top' })
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
generatingPayLink.value = false
}
}
const isDraft = computed(() => props.doc.docstatus === 0)
const isSubmitted = computed(() => props.doc.docstatus === 1)
const isUnpaid = computed(() => isSubmitted.value && !props.doc.is_return && props.doc.outstanding_amount > 0)
// ERPNext workflow calls
async function erpCall (method, args) {
const res = await authFetch(BASE_URL + '/api/method/' + method, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
})
const json = await res.json()
if (!res.ok) {
const msg = json?.exception?.split('\n')[0] || json?._server_messages || json?.message || 'Erreur ' + res.status
throw new Error(msg)
}
return json
}
// Hub API calls (for payment operations)
async function hubPost (path, body) {
const res = await fetch(HUB_URL + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'Hub error ' + res.status)
return data
}
async function submitInvoice () {
$q.dialog({
title: 'Soumettre cette facture ?',
message: `${props.docName}${formatMoney(props.doc.grand_total)}\nLa facture sera validee et le solde sera exigible.`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'green-7', label: 'Soumettre', flat: true },
persistent: true,
}).onOk(async () => {
actionLoading.value = true
try {
// Fetch the full doc, set docstatus=1, then amend via frappe.client.submit
const fullDoc = await getDoc('Sales Invoice', props.docName)
fullDoc.docstatus = 1
await erpCall('frappe.client.submit', { doc: fullDoc })
props.doc.docstatus = 1
localStatus.value = 'Unpaid'
props.doc.status = 'Unpaid'
Notify.create({ type: 'positive', message: `Facture ${props.docName} soumise`, position: 'top' })
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
actionLoading.value = false
}
})
}
async function cancelInvoice () {
$q.dialog({
title: 'Annuler cette facture ?',
message: `${props.docName}${formatMoney(props.doc.grand_total)}\nLa facture sera annulee et le solde remis a zero.`,
cancel: { flat: true, label: 'Non' },
ok: { color: 'orange-8', label: 'Annuler la facture', flat: true },
persistent: true,
}).onOk(async () => {
actionLoading.value = true
try {
await erpCall('frappe.client.cancel', {
doctype: 'Sales Invoice',
name: props.docName,
})
props.doc.docstatus = 2
localStatus.value = 'Cancelled'
props.doc.status = 'Cancelled'
Notify.create({ type: 'positive', message: `Facture ${props.docName} annulee`, position: 'top' })
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
actionLoading.value = false
}
})
}
async function deleteInvoice () {
$q.dialog({
title: 'Supprimer ce brouillon ?',
message: `${props.docName} sera supprime definitivement.`,
cancel: { flat: true, label: 'Non' },
ok: { color: 'red-6', label: 'Supprimer', flat: true },
persistent: true,
}).onOk(async () => {
actionLoading.value = true
try {
await deleteDoc('Sales Invoice', props.docName)
Notify.create({ type: 'positive', message: `Facture ${props.docName} supprimee`, position: 'top' })
emit('deleted', props.docName)
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
actionLoading.value = false
}
})
}
async function createCreditNote () {
$q.dialog({
title: 'Creer une note de credit ?',
message: `Un renversement complet de ${props.docName} (${formatMoney(props.doc.grand_total)}) sera cree.\nCela annule le solde de cette facture.`,
cancel: { flat: true, label: 'Non' },
ok: { color: 'red-7', label: 'Creer note de credit', flat: true },
persistent: true,
}).onOk(async () => {
actionLoading.value = true
try {
const result = await erpCall('erpnext.accounts.doctype.sales_invoice.sales_invoice.make_sales_return', {
source_name: props.docName,
})
// result.message is the new Credit Note doc (unsaved)
const creditDoc = result.message
if (!creditDoc) throw new Error('No credit note returned')
// Save the credit note
const saveRes = await authFetch(BASE_URL + '/api/resource/Sales%20Invoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(creditDoc),
})
const saved = await saveRes.json()
if (!saveRes.ok) throw new Error(saved?.exception?.split('\n')[0] || 'Save failed')
const cnName = saved.data?.name
if (!cnName) throw new Error('Credit note save returned no name')
// Submit the credit note — fetch the full saved doc first
const cnDoc = await getDoc('Sales Invoice', cnName)
cnDoc.docstatus = 1
await erpCall('frappe.client.submit', { doc: cnDoc })
Notify.create({ type: 'positive', message: `Note de credit ${cnName} creee et soumise`, position: 'top', timeout: 5000 })
emit('navigate', 'Sales Invoice', cnName, 'Note de credit ' + cnName)
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
actionLoading.value = false
}
})
}
async function sendPaymentLink () {
$q.dialog({
title: 'Envoyer un lien de paiement',
message: `Le client recevra un lien par SMS et courriel pour payer la facture ${props.docName} (${formatMoney(props.doc.outstanding_amount)}).`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'primary', label: 'Envoyer', flat: true },
persistent: true,
}).onOk(async () => {
sendingLink.value = true
try {
const result = await hubPost('/payments/send-link', {
customer: props.doc.customer,
invoice: props.docName,
channel: 'both',
})
const sentVia = result.sent?.join(' et ') || 'envoi'
Notify.create({ type: 'positive', message: `Lien de paiement envoye via ${sentVia}`, position: 'top' })
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
sendingLink.value = false
}
})
}
async function chargeSavedCard () {
$q.dialog({
title: 'Charger la carte du client ?',
message: `Debiter ${formatMoney(props.doc.outstanding_amount)} de la carte enregistree du client pour la facture ${props.docName}.`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'teal-7', label: 'Charger', flat: true },
persistent: true,
}).onOk(async () => {
chargingCard.value = true
try {
const result = await hubPost('/payments/charge', {
customer: props.doc.customer,
invoice: props.docName,
})
if (result.ok && result.status === 'succeeded') {
Notify.create({ type: 'positive', message: `Paiement de ${formatMoney(result.amount)} recu (${result.payment_intent})`, position: 'top' })
// Refresh the invoice doc
try {
const fresh = await getDoc('Sales Invoice', props.docName)
Object.assign(props.doc, { outstanding_amount: fresh.outstanding_amount, status: fresh.status })
localStatus.value = fresh.status
} catch {}
} else {
Notify.create({ type: 'warning', message: `Paiement en attente: ${result.status}`, position: 'top' })
}
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
chargingCard.value = false
}
})
}
</script>