gigafibre-fsm/apps/ops/src/components/shared/CreateInvoiceModal.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

352 lines
15 KiB
Vue

<template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" persistent>
<q-card style="width:700px;max-width:95vw">
<!-- Header -->
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="col">
<div class="text-subtitle1 text-weight-bold">Nouvelle facture</div>
<div v-if="customer?.customer_name" class="text-caption text-grey-6">{{ customer.customer_name }} ({{ customer.name }})</div>
</div>
<q-btn flat round dense icon="close" @click="$emit('update:modelValue', false)" />
</q-card-section>
<q-card-section class="q-pt-md">
<!-- Line items (the focus) -->
<div class="text-caption text-weight-bold q-mb-xs">Lignes</div>
<div v-for="(line, idx) in form.items" :key="idx" class="q-mb-sm">
<div class="row items-start q-gutter-xs">
<!-- Product autosuggest — type to search catalogue, or free description -->
<q-select
dense outlined class="col"
:model-value="line.sel"
:options="itemResults"
option-label="item_name"
use-input fill-input hide-selected input-debounce="250"
new-value-mode="add-unique" hide-bottom-space clearable
label="Produit ou description"
@filter="searchItems"
@update:model-value="opt => applyProduct(line, opt)"
@input-value="txt => onType(line, txt)"
@clear="() => resetLineProduct(line)">
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section>
<q-item-label>{{ scope.opt.item_name }}</q-item-label>
<q-item-label caption>{{ scope.opt.value }} · {{ scope.opt.item_group || '—' }}</q-item-label>
</q-item-section>
<q-item-section side v-if="scope.opt.rate">
<q-item-label class="text-weight-medium">{{ formatMoney(scope.opt.rate) }}</q-item-label>
</q-item-section>
</q-item>
</template>
<template #no-option>
<q-item><q-item-section class="text-grey-6 text-caption">Tapez pour chercher un produit — ou saisissez une description libre</q-item-section></q-item>
</template>
</q-select>
<q-input v-model.number="line.qty" label="Qté" type="number" dense outlined
style="width:64px" min="1" step="1" hide-bottom-space />
<q-input v-model.number="line.rate" label="Taux" type="number" dense outlined
style="width:106px" min="0" step="0.01" prefix="$" hide-bottom-space class="no-spin" />
<div style="width:84px" class="q-pt-sm text-right text-weight-medium">
{{ formatMoney((line.qty || 0) * (line.rate || 0)) }}
</div>
<q-btn flat round dense icon="delete" color="negative" size="sm" class="q-mt-xs"
:disable="form.items.length === 1" @click="removeLine(idx)">
<q-tooltip>Supprimer la ligne</q-tooltip>
</q-btn>
</div>
<!-- Resolved income account (transparent; flagged when it fell back to default) -->
<div v-if="line.item_code" class="text-caption q-pl-xs"
:class="line._confident === false ? 'text-warning' : 'text-grey-6'">
<q-icon :name="line._confident === false ? 'help_outline' : 'east'" size="13px" class="q-mr-xs" />{{ accountCaption(line) }}
</div>
</div>
<q-btn flat dense no-caps color="primary" icon="add" label="Ajouter une ligne"
@click="addLine" class="q-mt-xs" />
<!-- Totals -->
<q-separator class="q-my-sm" />
<div class="totals-grid">
<div class="row justify-between">
<span class="text-grey-7">Sous-total</span>
<span class="text-weight-medium">{{ formatMoney(subtotal) }}</span>
</div>
<div v-if="taxRate > 0" class="row justify-between">
<span class="text-grey-7">Taxes ({{ taxLabel }})</span>
<span class="text-weight-medium">{{ formatMoney(taxAmount) }}</span>
</div>
<div v-if="taxRate > 0 && isTwoPartTax" class="text-caption text-grey-5 q-ml-sm">
TPS 5%: {{ formatMoney(subtotal * 0.05) }} / TVQ 9.975%: {{ formatMoney(subtotal * 0.09975) }}
</div>
<q-separator class="q-my-xs" />
<div class="row justify-between text-subtitle2">
<span class="text-weight-bold">Total</span>
<span class="text-weight-bold">{{ formatMoney(grandTotal) }}</span>
</div>
</div>
<!-- Rarely-touched settings, tucked away -->
<q-separator class="q-mt-sm" />
<q-expansion-item dense flat icon="tune" :label="advancedSummary"
header-class="text-grey-7 text-caption q-px-none">
<div class="q-pt-sm q-gutter-sm">
<!-- Due terms -->
<div>
<div class="text-caption text-grey-6 q-mb-xs">Échéance (selon le crédit du client)</div>
<div class="row items-center q-gutter-sm">
<q-btn-toggle v-model="dueTerms" no-caps unelevated dense
toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[{label:'Prépayé', value:0},{label:'15 jours', value:15},{label:'30 jours', value:30}]" />
<span class="text-caption text-grey-6">→ {{ form.due_date }}</span>
</div>
</div>
<!-- Dates -->
<div class="row q-gutter-sm">
<q-input v-model="form.posting_date" label="Date de facturation" type="date" dense outlined class="col" hide-bottom-space />
<q-input v-model="form.due_date" label="Date d'échéance" type="date" dense outlined class="col" hide-bottom-space
@update:model-value="dueTerms = 'custom'" />
</div>
<!-- Taxes -->
<q-select v-model="form.taxes_and_charges" :options="taxOptions" label="Taxes"
dense outlined emit-value map-options hide-bottom-space />
<!-- Default income account (for free-text lines without a product) -->
<q-select v-model="form.default_income_account" :options="incomeAccountOptions"
label="Compte de revenu par défaut (lignes sans produit)"
dense outlined emit-value map-options hide-bottom-space />
</div>
</q-expansion-item>
</q-card-section>
<!-- Actions -->
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat label="Annuler" color="grey-7" @click="$emit('update:modelValue', false)" />
<q-btn unelevated label="Créer brouillon" color="primary" icon="save"
:loading="submitting" :disable="!canSubmit" @click="onSubmit" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Notify } from 'quasar'
import { createDoc, listDocs } from 'src/api/erp'
import { formatMoney } from 'src/composables/useFormatters'
import {
DEFAULT_INCOME_ACCOUNT, INCOME_ACCOUNT_OPTIONS,
resolveIncomeAccount, incomeAccountLabel,
} from 'src/data/income-accounts'
const props = defineProps({
modelValue: { type: Boolean, default: false },
customer: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:modelValue', 'created'])
const TAX_QC = 'QC TPS 5% + TVQ 9.975% - T'
const TAX_GST = 'Canada GST 5% - T'
const TAX_NONE = ''
const taxOptions = [
{ label: 'QC TPS 5% + TVQ 9.975%', value: TAX_QC },
{ label: 'Canada GST 5%', value: TAX_GST },
{ label: 'Aucune taxe (Exempt)', value: TAX_NONE },
]
const incomeAccountOptions = INCOME_ACCOUNT_OPTIONS
const submitting = ref(false)
const dueTerms = ref(30) // 0 = prépayé · 15 · 30 · 'custom'
const itemResults = ref([]) // product search results (shared by the active dropdown)
function defaultLine () {
return { sel: null, description: '', qty: 1, rate: 0, item_code: '', item_group: '', income_account: DEFAULT_INCOME_ACCOUNT, _confident: null }
}
function todayStr () { return new Date().toISOString().slice(0, 10) }
function addDays (dateStr, days) {
const d = new Date(dateStr)
d.setDate(d.getDate() + days)
return d.toISOString().slice(0, 10)
}
const form = ref({
posting_date: todayStr(),
due_date: addDays(todayStr(), 30),
taxes_and_charges: TAX_QC,
default_income_account: DEFAULT_INCOME_ACCOUNT,
items: [defaultLine()],
})
// Reset when the dialog opens
watch(() => props.modelValue, (open) => {
if (open) {
const isExempt = props.customer?.tax_category_legacy === 'Exempt'
dueTerms.value = 30
itemResults.value = []
form.value = {
posting_date: todayStr(),
due_date: addDays(todayStr(), 30),
taxes_and_charges: isExempt ? TAX_NONE : TAX_QC,
default_income_account: DEFAULT_INCOME_ACCOUNT,
items: [defaultLine()],
}
}
})
// Recompute due date from the selected term (unless the user set a custom date)
watch([dueTerms, () => form.value.posting_date], () => {
if (dueTerms.value !== 'custom' && form.value.posting_date) {
form.value.due_date = addDays(form.value.posting_date, dueTerms.value)
}
})
// ── Product search (ERPNext Item) ────────────────────────────────────────────
async function searchItems (txt, update, abort) {
try {
const like = `%${(txt || '').trim()}%`
const items = await listDocs('Item', {
filters: { is_sales_item: 1, disabled: 0 },
or_filters: [['item_name', 'like', like], ['item_code', 'like', like]],
fields: ['name', 'item_name', 'item_group', 'standard_rate'],
limit: 15,
orderBy: 'item_name asc',
})
update(() => {
itemResults.value = items.map(it => ({
item_name: it.item_name || it.name,
value: it.name,
item_group: it.item_group || '',
rate: parseFloat(it.standard_rate || 0),
}))
})
} catch { update(() => { itemResults.value = [] }) }
}
function applyProduct (line, opt) {
if (opt && typeof opt === 'object') {
line.sel = opt
line.description = opt.item_name || opt.value
line.item_code = opt.value
line.item_group = opt.item_group || ''
if (opt.rate) line.rate = opt.rate // don't wipe a manual rate with a $0 item
const r = resolveIncomeAccount(opt.item_group)
line.income_account = r.account
line._confident = r.confident
} else if (typeof opt === 'string') {
line.sel = opt
line.description = opt
line.item_code = ''
line.item_group = ''
line._confident = null
line.income_account = form.value.default_income_account
} else {
resetLineProduct(line)
}
}
function onType (line, txt) { line.description = txt }
function resetLineProduct (line) {
line.sel = null; line.item_code = ''; line.item_group = ''; line._confident = null
line.description = ''; line.income_account = form.value.default_income_account
}
function accountCaption (line) {
const lbl = incomeAccountLabel(line.income_account)
return line._confident === false ? `${lbl} — compte par défaut, à vérifier` : lbl
}
function addLine () { form.value.items.push(defaultLine()) }
function removeLine (idx) { if (form.value.items.length > 1) form.value.items.splice(idx, 1) }
// ── Totals ───────────────────────────────────────────────────────────────────
const subtotal = computed(() => form.value.items.reduce((s, l) => s + (l.qty || 0) * (l.rate || 0), 0))
const isTwoPartTax = computed(() => form.value.taxes_and_charges === TAX_QC)
const taxRate = computed(() => {
if (form.value.taxes_and_charges === TAX_QC) return 0.14975
if (form.value.taxes_and_charges === TAX_GST) return 0.05
return 0
})
const taxLabel = computed(() => {
if (form.value.taxes_and_charges === TAX_QC) return '14.975%'
if (form.value.taxes_and_charges === TAX_GST) return '5%'
return ''
})
const taxAmount = computed(() => subtotal.value * taxRate.value)
const grandTotal = computed(() => subtotal.value + taxAmount.value)
const termLabel = computed(() => {
if (dueTerms.value === 'custom') return `échéance ${form.value.due_date}`
if (dueTerms.value === 0) return 'prépayé'
return `échéance ${dueTerms.value} j`
})
const advancedSummary = computed(() => {
const tax = taxOptions.find(t => t.value === form.value.taxes_and_charges)?.label || 'Sans taxe'
return `Options — ${tax} · ${termLabel.value}`
})
const canSubmit = computed(() => form.value.items.some(l => l.description?.trim() && l.rate > 0))
async function onSubmit () {
if (!canSubmit.value) return
submitting.value = true
try {
const items = form.value.items
.filter(l => l.description?.trim() && l.rate > 0)
.map(l => {
const row = {
item_code: l.item_code || 'SVC',
description: l.description.trim(),
qty: l.qty || 1,
rate: l.rate,
income_account: l.income_account || form.value.default_income_account,
}
if (!l.item_code) row.item_name = l.description.trim().slice(0, 140)
return row
})
const payload = {
doctype: 'Sales Invoice',
customer: props.customer?.name,
posting_date: form.value.posting_date,
due_date: form.value.due_date,
company: 'TARGO',
debit_to: 'Comptes clients - T',
currency: 'CAD',
items,
}
const taxSel = form.value.taxes_and_charges
if (taxSel === TAX_QC) {
payload.taxes_and_charges = TAX_QC
payload.taxes = [
{ charge_type: 'On Net Total', account_head: '2300 - TPS perçue - T', description: 'TPS 5%', rate: 5 },
{ charge_type: 'On Net Total', account_head: '2350 - TVQ perçue - T', description: 'TVQ 9.975%', rate: 9.975 },
]
} else if (taxSel === TAX_GST) {
payload.taxes_and_charges = TAX_GST
payload.taxes = [
{ charge_type: 'On Net Total', account_head: '2300 - TPS perçue - T', description: 'TPS 5%', rate: 5 },
]
}
const doc = await createDoc('Sales Invoice', payload)
Notify.create({ type: 'positive', message: `Facture ${doc.name} créée (brouillon)`, position: 'top' })
emit('created', doc)
emit('update:modelValue', false)
} catch (err) {
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}`, position: 'top' })
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.totals-grid { max-width: 320px; margin-left: auto; }
/* Hide number-input spinner arrows (useless here) */
.no-spin :deep(input[type=number])::-webkit-outer-spin-button,
.no-spin :deep(input[type=number])::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.no-spin :deep(input[type=number]) { -moz-appearance: textfield; }
</style>