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>
393 lines
17 KiB
Vue
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' }} · {{ 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>
|