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:
parent
64d5751149
commit
218f6fc7b1
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user