diff --git a/apps/ops/src/components/shared/DetailModal.vue b/apps/ops/src/components/shared/DetailModal.vue index d606fa7..561f289 100644 --- a/apps/ops/src/components/shared/DetailModal.vue +++ b/apps/ops/src/components/shared/DetailModal.vue @@ -36,6 +36,7 @@ @dispatch-created="(...a) => $emit('dispatch-created', ...a)" @dispatch-deleted="(...a) => $emit('dispatch-deleted', ...a)" @dispatch-updated="(...a) => $emit('dispatch-updated', ...a)" + @contract-terminated="(...a) => $emit('contract-terminated', ...a)" @deleted="onDeleted" @open-pdf="(...a) => $emit('open-pdf', ...a)" /> @@ -64,13 +65,16 @@ import IssueDetail from './detail-sections/IssueDetail.vue' import PaymentDetail from './detail-sections/PaymentDetail.vue' import SubscriptionDetail from './detail-sections/SubscriptionDetail.vue' import EquipmentDetail from './detail-sections/EquipmentDetail.vue' +import ServiceContractDetail from './detail-sections/ServiceContractDetail.vue' const SECTION_MAP = { 'Sales Invoice': InvoiceDetail, 'Issue': IssueDetail, 'Payment Entry': PaymentDetail, 'Subscription': SubscriptionDetail, + 'Service Subscription': SubscriptionDetail, 'Service Equipment': EquipmentDetail, + 'Service Contract': ServiceContractDetail, } const props = defineProps({ @@ -87,7 +91,7 @@ const props = defineProps({ dispatchJobs: { type: Array, default: () => [] }, }) -const emit = defineEmits(['update:open', 'navigate', 'open-pdf', 'save-field', 'toggle-recurring', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated', 'deleted']) +const emit = defineEmits(['update:open', 'navigate', 'open-pdf', 'save-field', 'toggle-recurring', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated', 'deleted', 'contract-terminated']) function onDeleted (docName) { emit('update:open', false) diff --git a/apps/ops/src/components/shared/detail-sections/ServiceContractDetail.vue b/apps/ops/src/components/shared/detail-sections/ServiceContractDetail.vue new file mode 100644 index 0000000..306b5aa --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/ServiceContractDetail.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/apps/ops/src/pages/ClientDetailPage.vue b/apps/ops/src/pages/ClientDetailPage.vue index 709dce5..2d51b45 100644 --- a/apps/ops/src/pages/ClientDetailPage.vue +++ b/apps/ops/src/pages/ClientDetailPage.vue @@ -948,6 +948,7 @@ @open-pdf="openPdf" @save-field="saveSubField" @toggle-recurring="toggleRecurringModal" @dispatch-created="onDispatchCreated" @dispatch-deleted="name => { modalDispatchJobs = modalDispatchJobs.filter(j => j.name !== name) }" + @contract-terminated="onContractTerminated" @deleted="onEntityDeleted" /> @@ -1216,19 +1217,22 @@ function onSubPriceSaved (sub, evt) { } } -// ─── Subscription delete with Service Contract awareness ───────────── -// ERPNext blocks DELETE on a Service Subscription while any Service -// Contract.service_subscription still references it (LinkExistsError). -// When we detect a live contract we upgrade the dialog to: -// • show contract type, term, months remaining -// • preview the termination fee (résidentiel = avantages résiduels; -// commercial = mensualités restantes) via /contract/calculate-termination -// • offer three outcomes: suspend-only, full termination (fires -// /contract/terminate + creates the invoice), or cancel. -// If the linked contract is already terminal (Résilié/Complété) we just -// scrub the link inline so a delete of a stale link doesn't explode. +// ─── Subscription delete ───────────────────────────────────────────── +// Best-practice separation of concerns: +// • Hard delete is reserved for "dispatch typos" — subs with no +// billing history and no live contract. Anything else keeps the +// row for audit, chargeback defense, and regulatory retention. +// • If a live Service Contract references the sub, we redirect the +// rep to the contract detail modal, where they see the full +// benefits breakdown (économies accordées, reconnues à date, et +// résiduelles à rembourser) and can hit "Résilier" with the full +// fee calculation visible. The contract flow cascades a soft +// cancel to the sub (status=Annulé + end_date), never a DELETE. +// • If the linked contract is already terminal (Résilié / Complété +// / Expiré), the lifecycle is done — we scrub the stale link +// inline and allow the delete without another round-trip. -const CONTRACT_TERMINAL = new Set(['Résilié', 'Complété']) +const CONTRACT_TERMINAL = new Set(['Résilié', 'Complété', 'Expiré']) function findLinkedContract (sub) { return (serviceContracts.value || []).find(c => c.service_subscription === sub.name) || null @@ -1252,117 +1256,43 @@ async function rawDeleteSub (sub, label) { Notify.create({ type: 'positive', message: `${label} supprimé`, position: 'top', timeout: 2500 }) } -async function fetchTerminationPreview (contractName) { - try { - const r = await fetch(`${HUB_URL}/contract/calculate-termination`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: contractName }), - }) - if (!r.ok) return null - return await r.json() - } catch { return null } -} - -async function terminateAndDelete (sub, contract, label, reason) { - try { - // 1) Hit /contract/terminate — sets status=Résilié, fills termination - // fields, creates the penalty invoice (returned as termination_invoice). - const r = await fetch(`${HUB_URL}/contract/terminate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: contract.name, reason, create_invoice: true }), - }) - if (!r.ok) { - const body = await r.json().catch(() => ({})) - throw new Error(body.error || 'Résiliation échouée') - } - const termResult = await r.json() - - // 2) Scrub the link so the sub DELETE succeeds; contract is now Résilié - // so the ERPNext UI / reports still show the full audit trail. - await unlinkContractFromSub(contract) - contract.status = 'Résilié' - - // 3) Delete the sub + log the termination on the customer timeline. - await rawDeleteSub(sub, label) - const fee = Number(termResult.termination_fee_total || 0) - const invoiceTag = termResult.termination_invoice - ? ` — facture ${termResult.termination_invoice} (${formatMoney(fee)})` - : (fee > 0 ? ` — pénalité ${formatMoney(fee)}` : '') - logSubChange(sub, `Contrat ${contract.name} résilié${invoiceTag}`) - } catch (e) { - Notify.create({ type: 'negative', message: 'Erreur résiliation: ' + (e.message || 'échec'), position: 'top', timeout: 5000 }) - } -} - async function confirmDeleteSub (sub) { const label = sub.plan_name || sub.item_name || sub.name const contract = findLinkedContract(sub) - - // Already-terminated contract still carries the link — scrub it silently - // inside the normal confirm path so we don't make the dispatcher go - // re-open a résilié record just to remove a stale reference. const activeContract = contract && !CONTRACT_TERMINAL.has(contract.status) ? contract : null + // Active contract → redirect to contract detail. All termination UX + // (benefits table, refund column, penalty calc, invoice creation, + // cascade cancel) lives there. if (activeContract) { - // Contract-aware path: fetch the termination preview first so the fee - // is shown inside the dialog (not discovered post-click). - const calc = await fetchTerminationPreview(activeContract.name) - const monthsRemaining = calc?.months_remaining ?? activeContract.duration_months ?? 0 - const monthsElapsed = calc?.months_elapsed ?? 0 - const fee = Number(calc?.termination_fee_total || 0) - const feeBenefits = Number(calc?.termination_fee_benefits || 0) - const feeRemaining = Number(calc?.termination_fee_remaining || 0) - const endDate = activeContract.end_date ? formatDate(activeContract.end_date) : '—' - const typ = activeContract.contract_type || '' - - const feeBreakdown = fee > 0 - ? `
-
Pénalité de bris de contrat: ${formatMoney(fee)}
- ${feeBenefits > 0 ? `
• Avantages à rembourser: ${formatMoney(feeBenefits)}
` : ''} - ${feeRemaining > 0 ? `
• Mensualités restantes: ${formatMoney(feeRemaining)}
` : ''} -
Une facture de résiliation sera créée automatiquement.
-
` - : '
✓ Aucune pénalité applicable.
' - $q.dialog({ - title: `Contrat actif — ${activeContract.name}`, - message: - `
${sub.name} — ${label} (${formatMoney(sub.actual_price)}/mois)
-
-
${typ} — du ${formatDate(activeContract.start_date)} au ${endDate}
-
${monthsElapsed} mois écoulés, ${monthsRemaining} mois restants
-
- ${feeBreakdown} -
Action à effectuer :
`, + title: 'Contrat actif', + message: ` +
${sub.name} — ${label}
+
+
Ce service est sous contrat ${activeContract.name}
+
+ ${activeContract.contract_type || ''} — du ${formatDate(activeContract.start_date) || '—'} + au ${formatDate(activeContract.end_date) || '—'} +
+
+
+ Pour supprimer ce service il faut d'abord résilier le contrat + (les avantages accordés, déjà reconnus, et à rembourser + sont affichés sur la fiche du contrat). +
+ `, html: true, - options: { - type: 'radio', - model: 'suspend', - items: [ - { label: 'Désactiver le service (conserver le contrat)', value: 'suspend' }, - { label: fee > 0 - ? `Résilier le contrat + facturer ${formatMoney(fee)} + supprimer le service` - : 'Résilier le contrat et supprimer le service', - value: 'terminate' }, - ], - }, cancel: { flat: true, label: 'Annuler' }, - ok: { color: 'primary', label: 'Valider', unelevated: true }, - persistent: true, - }).onOk(async (choice) => { - if (choice === 'suspend') { - await toggleSubStatus(sub) - } else if (choice === 'terminate') { - await terminateAndDelete(sub, activeContract, label, 'Bris de contrat à la demande du client') - } + ok: { color: 'primary', label: 'Voir le contrat', unelevated: true, icon: 'handshake' }, + persistent: false, + }).onOk(() => { + openModal('Service Contract', activeContract.name, 'Contrat ' + activeContract.name) }) return } - // No-contract path (or contract already résilié/complété): keep the - // existing two-button confirm, but scrub any stale link first. + // No contract OR terminal-state contract (link is stale) → plain delete. $q.dialog({ title: 'Supprimer ce service ?', message: `
${sub.name} — ${label} (${formatMoney(sub.actual_price)})
` @@ -1457,6 +1387,42 @@ function onEntityDeleted (docName) { invoices.value = invoices.value.filter(i => i.name !== docName) } +// Fired from the Service Contract detail modal after a successful +// /contract/terminate. We reflect the cascade locally so the user +// doesn't need to refresh: the sub row flips to Annulé, the contract +// row flips to Résilié, and the fresh termination invoice pops into +// the invoice list if it's within the 5-latest window. +function onContractTerminated (payload) { + const today = new Date().toISOString().slice(0, 10) + const contractRow = serviceContracts.value.find(c => c.name === payload.contract) + if (contractRow) { + contractRow.status = 'Résilié' + contractRow.service_subscription = '' + } + if (payload.service_subscription) { + const subRow = subscriptions.value.find(s => s.name === payload.service_subscription) + if (subRow) { + subRow.status = 'Cancelled' + subRow.cancelation_date = today + if (!subRow.end_date) subRow.end_date = today + if (subRow.service_location) invalidateCache(subRow.service_location) + } + } + if (payload.termination_invoice) { + // Drop an audit line on the customer so the termination shows + // up in the notes feed alongside the fresh invoice. + authFetch(BASE_URL + '/api/resource/Comment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + doctype: 'Comment', comment_type: 'Comment', + reference_doctype: 'Customer', reference_name: customer.value?.name, + content: `Contrat ${payload.contract} résilié — facture ${payload.termination_invoice} (${formatMoney(payload.termination_fee_total)})`, + }), + }).then(r => r.ok && r.json()).then(d => d?.data && comments.value.unshift(d.data)).catch(() => {}) + } +} + const sectionsOpen = ref({ ...defaultSectionsOpen }) const openTicketCount = computed(() => tickets.value.filter(t => t.status === 'Open').length)