gigafibre-fsm/apps/ops/src/components/shared/CreateInvoiceModal.vue
louispaulb 607ea54b5c refactor: reduce token count, DRY code, consolidate docs
Backend services:
- targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons
  lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas,
  extract dispatch scoring weights, trim section dividers across 9 files
- modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(),
  consolidate DM query factory, fix duplicate username fill bug, trim headers
  (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%)

Frontend:
- useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into
  6 focused helpers (processOnlineStatus, processWanIPs, processRadios,
  processMeshNodes, processClients, checkRadioIssues)
- EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments

Documentation (17 → 13 files, -1,400 lines):
- New consolidated README.md (architecture, services, dependencies, auth)
- Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md
- Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md
- Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md
- Update ROADMAP.md with current phase status
- Delete CONTEXT.md (absorbed into README)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:39:58 -04:00

258 lines
8.7 KiB
Vue

<template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" persistent>
<q-card style="width:680px;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>
<!-- Form -->
<q-card-section class="q-pt-md q-gutter-sm">
<!-- Dates -->
<div class="row q-gutter-sm">
<q-input v-model="form.posting_date" label="Date de facturation" type="date" dense outlined class="col" />
<q-input v-model="form.due_date" label="Date d'echeance" type="date" dense outlined class="col" />
</div>
<!-- Tax template -->
<q-select v-model="form.taxes_and_charges" :options="taxOptions" label="Taxes"
dense outlined emit-value map-options />
<!-- Income account -->
<q-select v-model="form.income_account" :options="incomeAccountOptions" label="Compte de revenu"
dense outlined emit-value map-options />
<!-- Line items -->
<div class="text-caption text-weight-bold q-mt-sm q-mb-xs">Lignes</div>
<div v-for="(line, idx) in form.items" :key="idx" class="row items-start q-gutter-xs q-mb-xs">
<q-input v-model="line.description" label="Description" dense outlined class="col"
:rules="[v => !!v?.trim() || 'Requis']" />
<q-input v-model.number="line.qty" label="Qte" type="number" dense outlined
style="width:70px" min="1" step="1" />
<q-input v-model.number="line.rate" label="Taux" type="number" dense outlined
style="width:110px" min="0" step="0.01" prefix="$" />
<div style="width:90px" class="q-pt-sm text-right text-weight-medium">
{{ formatMoney(line.qty * line.rate) }}
</div>
<q-btn flat round dense icon="delete" color="red-5" size="sm" class="q-mt-xs"
:disable="form.items.length === 1" @click="removeLine(idx)">
<q-tooltip>Supprimer la ligne</q-tooltip>
</q-btn>
</div>
<q-btn flat dense no-caps color="indigo-6" 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>
</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="Creer brouillon" color="indigo-6" 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 } from 'src/api/erp'
import { formatMoney } from 'src/composables/useFormatters'
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 = [
{ label: '4020 - Mensualite fibre', value: '4020 - Mensualité fibre - T' },
{ label: '4017 - Installation et equipement fibre', value: '4017 - Installation et équipement fibre - T' },
]
const submitting = ref(false)
function defaultLine () {
return { description: '', qty: 1, rate: 0 }
}
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,
income_account: '4020 - Mensualité fibre - T',
items: [defaultLine()],
})
// Reset form when dialog opens
watch(() => props.modelValue, (open) => {
if (open) {
const isExempt = props.customer?.tax_category_legacy === 'Exempt'
form.value = {
posting_date: todayStr(),
due_date: addDays(todayStr(), 30),
taxes_and_charges: isExempt ? TAX_NONE : TAX_QC,
income_account: '4020 - Mensualité fibre - T',
items: [defaultLine()],
}
}
})
function addLine () {
form.value.items.push(defaultLine())
}
function removeLine (idx) {
if (form.value.items.length > 1) {
form.value.items.splice(idx, 1)
}
}
// Computed totals
const subtotal = computed(() =>
form.value.items.reduce((sum, l) => sum + (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 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 => ({
item_code: 'SVC',
item_name: l.description.trim(),
description: l.description.trim(),
qty: l.qty || 1,
rate: l.rate,
income_account: form.value.income_account,
}))
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,
}
// Include tax rows directly (ERPNext tax templates are empty shells)
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} creee (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;
}
</style>