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 @@
+
+
+
+
+
+
+ {{ doc.contract_type || 'Contrat' }} — {{ doc.status }}
+
+
+ {{ doc.duration_months || 0 }} mois • {{ formatDate(doc.start_date) || '—' }}
+ → {{ formatDate(doc.end_date) || '—' }}
+
+
+
+
+
+
+
+
+
+ {{ monthsElapsed }} mois écoulés / {{ doc.duration_months }} mois
+
+ {{ monthsRemaining > 0 ? monthsRemaining + ' mois restants' : 'Terme complété' }}
+
+
+
+
+
+
+
+
+ Mensualité
+ {{ formatMoney(doc.monthly_rate) }}/mois
+
+
+ Abonnement
+
+ {{ doc.service_subscription }}
+
+ —
+
+
+ Devis
+
+ {{ doc.quotation }}
+
+ —
+
+
+ Lieu
+
+ {{ doc.service_location }}
+
+ —
+
+
+ Avantages totaux
+ {{ formatMoney(doc.total_benefit_value) }}
+
+
+ Résiduel
+
+ {{ formatMoney(doc.total_remaining_value) }}
+
+
+
+ Signé le{{ formatDate(doc.signed_at) }}
+
+
+ Méthode{{ doc.acceptance_method }}
+
+
+
+
+
+
+
+ Avantages accordés ({{ doc.benefits.length }})
+
+
+
+
+ | Description |
+ Régulier |
+ Accordé |
+ Économie |
+ Reconnu |
+ À rembourser |
+
+
+
+
+ | {{ b.description || '—' }} |
+ {{ formatMoney(b.regular_price) }} |
+ {{ formatMoney(b.granted_price) }} |
+ {{ formatMoney(b.benefit_value) }} |
+
+
+ {{ b.months_recognized || 0 }} × {{ formatMoney(b.monthly_recognition) }}
+
+ —
+ |
+ {{ formatMoney(b.remaining_value) }} |
+
+
+ | Totaux |
+ {{ formatMoney(totalBenefits) }} |
+ |
+ {{ formatMoney(totalRemaining) }} |
+
+
+
+
+
+ La colonne « À rembourser » = ce que le client devrait refaire payer en cas de bris de contrat résidentiel.
+
+
+
+
+
+
+ Résiliation
+
+
+
Date{{ formatDate(doc.terminated_at) || '—' }}
+
+ Pénalité
+ {{ formatMoney(doc.termination_fee_total) }}
+
+
+ Avantages{{ formatMoney(doc.termination_fee_benefits) }}
+
+
+ Mensualités{{ formatMoney(doc.termination_fee_remaining) }}
+
+
+
+ Raison: {{ doc.termination_reason }}
+
+
+
+
+
+
Mention sur facture
+
{{ doc.invoice_note }}
+
+
+
+
+
+
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)