feat(ops): Service Contract detail view + sub-delete redirects there

Contract termination is a fee-bearing, auditable workflow — it belongs
on the contract, not buried in a sub's delete dialog. Standard SaaS /
telecom practice: subs are an immutable event stream, contracts
orchestrate their lifecycle.

ServiceContractDetail.vue (new)
  • Status banner: contract type, dates, status — "Résilier" button
    when actionable, termination invoice link when already résilié.
  • Term progress bar: months_elapsed / duration_months with color
    ramp (primary → amber near end → positive when done).
  • Financial summary grid: mensualité, abonnement (clickable), devis,
    lieu, total avantages, résiduel, signature method & date.
  • Benefits detail table: per-row description, regular_price vs
    granted_price, économie, reconnu à date, et "À rembourser"
    (valeur résiduelle) — this is what the rep needs to see before
    deciding to break a contract.
  • Termination recap (only when status=Résilié): date, raison,
    penalty breakdown, link to the termination invoice.
  • "Résilier" action runs a 2-step dialog: first calls
    /contract/calculate-termination for the preview, then prompts for
    a reason (textarea, min 3 chars) before firing /contract/terminate.
    On success: cascade-cancels the linked sub (status=Annulé +
    end_date + cancellation_date — no hard delete), mutates the
    local doc so the modal refreshes in place, and emits
    contract-terminated so the parent page updates its sub + contract
    rows + drops an audit comment on the customer.

DetailModal
  • SECTION_MAP now routes Service Contract → ServiceContractDetail.
    Also added 'Service Subscription' → SubscriptionDetail (same
    template fits; was falling through to the generic grid).
  • Re-emits contract-terminated so the parent can listen.

ClientDetailPage
  • confirmDeleteSub: when a live contract references the sub, the
    dialog now simply redirects the rep to the contract modal
    ("Voir le contrat") instead of trying to do termination from
    the sub row. Terminal-state contracts (Résilié/Complété/Expiré)
    still get the inline link-scrub path so stale refs don't block
    a legit delete.
  • onContractTerminated: reflects the cascade locally — contract
    row → Résilié, sub row → Cancelled + end_date, audit Comment
    posted to the customer's notes feed.
This commit is contained in:
louispaulb 2026-04-23 14:46:34 -04:00
parent 64d5751149
commit 218f6fc7b1
3 changed files with 449 additions and 111 deletions

View File

@ -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)

View File

@ -0,0 +1,368 @@
<template>
<!-- STATUS BANNER
Big visual: contract type + status chip + primary action.
When Actif/Brouillon/Envoyé "Résilier" button (red).
When already Résilié termination invoice link + date. -->
<div class="contract-banner" :class="bannerClass">
<q-icon :name="bannerIcon" size="28px" />
<div class="col">
<div class="text-weight-bold" style="font-size:1rem">
{{ doc.contract_type || 'Contrat' }} {{ doc.status }}
</div>
<div class="text-caption text-grey-7">
{{ doc.duration_months || 0 }} mois {{ formatDate(doc.start_date) || '—' }}
{{ formatDate(doc.end_date) || '—' }}
</div>
</div>
<q-btn v-if="canTerminate" color="negative" unelevated dense no-caps
icon="cancel" label="Résilier le contrat"
:loading="isTerminating" @click="openTerminateDialog" />
<q-btn v-else-if="isTerminated && doc.termination_invoice" flat dense no-caps
icon="receipt" color="red-7" :label="doc.termination_invoice"
@click="$emit('navigate', 'Sales Invoice', doc.termination_invoice, 'Facture ' + doc.termination_invoice)" />
</div>
<!-- TERM PROGRESS
Linear progress of months_elapsed / duration so the rep
sees how far into the engagement the customer is. -->
<div v-if="doc.duration_months" class="q-mt-md">
<div class="row items-center justify-between text-caption q-mb-xs">
<span class="text-grey-7">{{ monthsElapsed }} mois écoulés / {{ doc.duration_months }} mois</span>
<span class="text-weight-medium" :class="monthsRemaining > 0 ? 'text-negative' : 'text-positive'">
{{ monthsRemaining > 0 ? monthsRemaining + ' mois restants' : 'Terme complété' }}
</span>
</div>
<q-linear-progress :value="progressPct" size="8px" rounded :color="progressColor" track-color="grey-3" />
</div>
<!-- FINANCIAL SUMMARY -->
<div class="modal-field-grid q-mt-md">
<div class="mf">
<span class="mf-label">Mensualité</span>
<span class="text-weight-medium">{{ formatMoney(doc.monthly_rate) }}/mois</span>
</div>
<div class="mf">
<span class="mf-label">Abonnement</span>
<code v-if="doc.service_subscription" class="link"
@click="$emit('navigate', 'Service Subscription', doc.service_subscription)">
{{ doc.service_subscription }}
</code>
<span v-else class="text-grey-5"></span>
</div>
<div class="mf">
<span class="mf-label">Devis</span>
<code v-if="doc.quotation" class="link"
@click="$emit('navigate', 'Quotation', doc.quotation, 'Soumission ' + doc.quotation)">
{{ doc.quotation }}
</code>
<span v-else class="text-grey-5"></span>
</div>
<div class="mf">
<span class="mf-label">Lieu</span>
<code v-if="doc.service_location" class="link"
@click="$emit('navigate', 'Service Location', doc.service_location)">
{{ doc.service_location }}
</code>
<span v-else class="text-grey-5"></span>
</div>
<div class="mf">
<span class="mf-label">Avantages totaux</span>
<span class="text-green-7 text-weight-medium">{{ formatMoney(doc.total_benefit_value) }}</span>
</div>
<div class="mf">
<span class="mf-label">Résiduel</span>
<span :class="Number(doc.total_remaining_value) > 0 ? 'text-red-7 text-weight-bold' : 'text-grey-5'">
{{ formatMoney(doc.total_remaining_value) }}
</span>
</div>
<div v-if="doc.signed_at" class="mf">
<span class="mf-label">Signé le</span>{{ formatDate(doc.signed_at) }}
</div>
<div v-if="doc.acceptance_method" class="mf">
<span class="mf-label">Méthode</span>{{ doc.acceptance_method }}
</div>
</div>
<!-- BENEFITS DETAIL
The "cadeaux / avantages" table the rep promised the
customer. For each row: regular vs granted price, benefit
value (économie), months recognized so far, and remaining
value to refund if the contract is broken. This is what
the user asked for "voir les cadeaux à rembourser". -->
<div v-if="doc.benefits?.length" class="q-mt-md">
<div class="info-block-title row items-center">
<q-icon name="card_giftcard" size="14px" class="q-mr-xs text-green-7" />
Avantages accordés ({{ doc.benefits.length }})
</div>
<q-markup-table flat dense bordered class="benefits-table">
<thead>
<tr>
<th class="text-left">Description</th>
<th class="text-right">Régulier</th>
<th class="text-right">Accordé</th>
<th class="text-right">Économie</th>
<th class="text-right">Reconnu</th>
<th class="text-right">À rembourser</th>
</tr>
</thead>
<tbody>
<tr v-for="b in doc.benefits" :key="b.name">
<td class="text-left">{{ b.description || '—' }}</td>
<td class="text-right text-grey-6">{{ formatMoney(b.regular_price) }}</td>
<td class="text-right">{{ formatMoney(b.granted_price) }}</td>
<td class="text-right text-green-7">{{ formatMoney(b.benefit_value) }}</td>
<td class="text-right text-grey-7" style="font-size:0.75rem">
<span v-if="b.monthly_recognition > 0">
{{ b.months_recognized || 0 }} × {{ formatMoney(b.monthly_recognition) }}
</span>
<span v-else></span>
</td>
<td class="text-right text-red-7 text-weight-bold">{{ formatMoney(b.remaining_value) }}</td>
</tr>
<tr class="totals-row">
<td class="text-right text-weight-bold" colspan="3">Totaux</td>
<td class="text-right text-green-7 text-weight-bold">{{ formatMoney(totalBenefits) }}</td>
<td></td>
<td class="text-right text-red-7 text-weight-bold">{{ formatMoney(totalRemaining) }}</td>
</tr>
</tbody>
</q-markup-table>
<div v-if="canTerminate && totalRemaining > 0" class="text-caption text-grey-7 q-mt-xs">
<q-icon name="info" size="12px" class="q-mr-xs" />
La colonne « À rembourser » = ce que le client devrait refaire payer en cas de bris de contrat résidentiel.
</div>
</div>
<!-- TERMINATION RECAP
Shown only when already résilié: date, reason, fee breakdown,
link to generated invoice. -->
<div v-if="isTerminated && (doc.terminated_at || doc.termination_fee_total)" class="q-mt-md term-box">
<div class="info-block-title text-red-7 row items-center">
<q-icon name="cancel" size="14px" class="q-mr-xs" /> Résiliation
</div>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Date</span>{{ formatDate(doc.terminated_at) || '—' }}</div>
<div class="mf">
<span class="mf-label">Pénalité</span>
<span class="text-red-7 text-weight-bold">{{ formatMoney(doc.termination_fee_total) }}</span>
</div>
<div v-if="doc.termination_fee_benefits > 0" class="mf">
<span class="mf-label">Avantages</span>{{ formatMoney(doc.termination_fee_benefits) }}
</div>
<div v-if="doc.termination_fee_remaining > 0" class="mf">
<span class="mf-label">Mensualités</span>{{ formatMoney(doc.termination_fee_remaining) }}
</div>
</div>
<div v-if="doc.termination_reason" class="q-mt-sm text-caption">
<span class="text-grey-7">Raison:</span> {{ doc.termination_reason }}
</div>
</div>
<!-- INVOICE NOTE -->
<div v-if="doc.invoice_note" class="q-mt-md">
<div class="info-block-title">Mention sur facture</div>
<div class="modal-desc">{{ doc.invoice_note }}</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useQuasar, Notify } from 'quasar'
import { formatDate, formatMoney } from 'src/composables/useFormatters'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
const props = defineProps({
doc: { type: Object, required: true },
docName: String,
})
const emit = defineEmits(['navigate', 'deleted', 'save-field', 'contract-terminated'])
const $q = useQuasar()
const isTerminating = ref(false)
// Residual contract states where the penalty/termination flow
// no longer applies. Also includes 'Expiré' since an expired
// contract runs out its term naturally no fee.
const TERMINAL_STATUSES = new Set(['Résilié', 'Complété', 'Expiré'])
const isTerminated = computed(() => TERMINAL_STATUSES.has(props.doc.status))
const canTerminate = computed(() => !isTerminated.value && props.doc.status !== 'Brouillon')
const monthsElapsed = computed(() => Number(props.doc.months_elapsed || 0))
const monthsRemaining = computed(() => {
const total = Number(props.doc.duration_months || 0)
return Math.max(0, total - monthsElapsed.value)
})
const progressPct = computed(() => {
const total = Number(props.doc.duration_months || 0)
if (!total) return 0
return Math.min(1, monthsElapsed.value / total)
})
const progressColor = computed(() => {
if (isTerminated.value) return 'grey-6'
if (progressPct.value >= 1) return 'positive'
if (progressPct.value >= 0.8) return 'amber-7'
return 'primary'
})
const bannerClass = computed(() => {
if (props.doc.status === 'Actif') return 'banner-active'
if (isTerminated.value) return 'banner-terminated'
if (props.doc.status === 'Brouillon') return 'banner-draft'
return 'banner-pending'
})
const bannerIcon = computed(() => {
if (isTerminated.value) return 'cancel'
if (props.doc.status === 'Actif') return 'verified'
if (props.doc.status === 'Brouillon') return 'edit_note'
return 'schedule_send'
})
const totalBenefits = computed(() =>
(props.doc.benefits || []).reduce((s, b) => s + Number(b.benefit_value || 0), 0)
)
const totalRemaining = computed(() =>
(props.doc.benefits || []).reduce((s, b) => s + Number(b.remaining_value || 0), 0)
)
// Termination workflow
// Two-step to give the rep a preview of the fee before firing the
// (destructive) /contract/terminate call. The hub endpoint handles
// the heavy lifting: computes fee, updates contract status, creates
// the Sales Invoice for the penalty. We then cascade-cancel the
// linked Service Subscription (status=Annulé + end_date) so the
// timeline stays coherent we never hard-delete.
async function openTerminateDialog () {
let calc
try {
const r = await fetch(`${HUB_URL}/contract/calculate-termination`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: props.docName }),
})
if (!r.ok) throw new Error('Calcul de pénalité impossible')
calc = await r.json()
} catch (e) {
Notify.create({ type: 'negative', message: e.message, position: 'top' })
return
}
const fee = Number(calc.termination_fee_total || 0)
const remain = Number(calc.months_remaining || 0)
const breakdown = fee > 0
? `<div class="q-mt-sm q-pa-sm" style="background:#fef2f2;border-left:3px solid #ef4444;border-radius:4px">
<div class="text-weight-bold text-negative">Pénalité: ${formatMoney(fee)}</div>
${calc.termination_fee_benefits > 0 ? `<div class="text-caption">• Avantages à rembourser: ${formatMoney(calc.termination_fee_benefits)}</div>` : ''}
${calc.termination_fee_remaining > 0 ? `<div class="text-caption">• Mensualités restantes: ${formatMoney(calc.termination_fee_remaining)}</div>` : ''}
<div class="text-caption text-grey-7 q-mt-xs">Une facture de résiliation sera créée.</div>
</div>`
: '<div class="text-caption text-positive q-mt-sm">✓ Aucune pénalité applicable.</div>'
$q.dialog({
title: `Résilier ${props.docName}`,
message: `
<div>${props.doc.customer_name || props.doc.customer || ''}</div>
<div class="text-caption text-grey-7">${remain} mois restants sur ${props.doc.duration_months || 0}</div>
${breakdown}
`,
html: true,
prompt: {
model: '',
type: 'textarea',
rows: 2,
outlined: true,
isValid: v => v.trim().length >= 3,
label: 'Raison de résiliation (3+ caractères)',
counter: true,
},
cancel: { flat: true, label: 'Annuler' },
ok: {
color: 'negative', unelevated: true, icon: 'cancel',
label: fee > 0 ? `Résilier + facturer ${formatMoney(fee)}` : 'Résilier',
},
persistent: true,
}).onOk(async (reason) => {
await runTerminate(reason || '')
})
}
async function runTerminate (reason) {
isTerminating.value = true
try {
const r = await fetch(`${HUB_URL}/contract/terminate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: props.docName, reason, create_invoice: true }),
})
if (!r.ok) {
const body = await r.json().catch(() => ({}))
throw new Error(body.error || 'Résiliation échouée')
}
const result = await r.json()
// Cascade: cancel the linked subscription (soft keep the row).
if (props.doc.service_subscription) {
const today = new Date().toISOString().slice(0, 10)
try {
await authFetch(`${BASE_URL}/api/resource/Service%20Subscription/${encodeURIComponent(props.doc.service_subscription)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'Annulé', end_date: today, cancellation_date: today }),
})
} catch {}
}
// Reflect new state locally the modal stays open with the
// résiliation recap + invoice link now visible.
Object.assign(props.doc, {
status: 'Résilié',
terminated_at: new Date().toISOString().slice(0, 10),
termination_reason: reason,
termination_fee_benefits: result.termination_fee_benefits,
termination_fee_remaining: result.termination_fee_remaining,
termination_fee_total: result.termination_fee_total,
termination_invoice: result.termination_invoice,
})
// Let the parent page refresh its local state (sub row Annulé,
// contract row Résilié) without a full reload.
emit('contract-terminated', {
contract: props.docName,
service_subscription: props.doc.service_subscription || null,
termination_invoice: result.termination_invoice || null,
termination_fee_total: result.termination_fee_total,
})
const msg = result.termination_invoice
? `Contrat résilié — facture ${result.termination_invoice} (${formatMoney(result.termination_fee_total)})`
: 'Contrat résilié (aucune pénalité)'
Notify.create({ type: 'positive', message: msg, position: 'top', timeout: 4500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, position: 'top', timeout: 5000 })
} finally {
isTerminating.value = false
}
}
</script>
<style scoped>
.contract-banner {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-radius: 10px;
border-left: 4px solid transparent;
}
.banner-active { background: #ecfdf5; border-left-color: #10b981; color: #065f46; }
.banner-active .q-icon { color: #10b981; }
.banner-draft { background: #f1f5f9; border-left-color: #94a3b8; color: #334155; }
.banner-draft .q-icon { color: #64748b; }
.banner-pending { background: #fffbeb; border-left-color: #f59e0b; color: #78350f; }
.banner-pending .q-icon { color: #d97706; }
.banner-terminated { background: #fef2f2; border-left-color: #ef4444; color: #7f1d1d; }
.banner-terminated .q-icon { color: #dc2626; }
.benefits-table { font-size: 0.82rem; }
.benefits-table :deep(th) { font-weight: 700; color: #475569; background: #f8fafc; }
.benefits-table :deep(td) { padding: 6px 8px; }
.benefits-table .totals-row :deep(td) { border-top: 2px solid #cbd5e1; }
.term-box { background: #fef2f2; border-radius: 8px; padding: 10px 12px; border-left: 3px solid #ef4444; }
.link { color: #4f46e5; cursor: pointer; text-decoration: none; }
.link:hover { text-decoration: underline; }
</style>

View File

@ -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" />
</q-page>
</template>
@ -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
? `<div class="q-mt-sm q-pa-sm" style="background:#fef2f2;border-left:3px solid #ef4444;border-radius:4px">
<div class="text-weight-bold text-negative">Pénalité de bris de contrat: ${formatMoney(fee)}</div>
${feeBenefits > 0 ? `<div class="text-caption text-grey-8">• Avantages à rembourser: ${formatMoney(feeBenefits)}</div>` : ''}
${feeRemaining > 0 ? `<div class="text-caption text-grey-8">• Mensualités restantes: ${formatMoney(feeRemaining)}</div>` : ''}
<div class="text-caption text-grey-7 q-mt-xs">Une facture de résiliation sera créée automatiquement.</div>
</div>`
: '<div class="text-caption text-positive q-mt-sm">✓ Aucune pénalité applicable.</div>'
$q.dialog({
title: `Contrat actif — ${activeContract.name}`,
message:
`<div><code>${sub.name}</code> — ${label} (${formatMoney(sub.actual_price)}/mois)</div>
<div class="q-mt-sm q-pa-sm" style="background:#f8fafc;border-radius:4px">
<div class="text-caption"><b>${typ}</b> du ${formatDate(activeContract.start_date)} au ${endDate}</div>
<div class="text-caption text-grey-7">${monthsElapsed} mois écoulés, <b>${monthsRemaining} mois restants</b></div>
</div>
${feeBreakdown}
<div class="text-caption q-mt-md">Action à effectuer :</div>`,
title: 'Contrat actif',
message: `
<div><code>${sub.name}</code> ${label}</div>
<div class="q-mt-sm q-pa-sm" style="background:#fef3c7;border-left:3px solid #d97706;border-radius:4px">
<div class="text-weight-bold">Ce service est sous contrat <code>${activeContract.name}</code></div>
<div class="text-caption text-grey-8 q-mt-xs">
${activeContract.contract_type || ''} du ${formatDate(activeContract.start_date) || '—'}
au ${formatDate(activeContract.end_date) || '—'}
</div>
</div>
<div class="text-caption q-mt-md">
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).
</div>
`,
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: `<div><code>${sub.name}</code> — ${label} (${formatMoney(sub.actual_price)})</div>`
@ -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)