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.
369 lines
16 KiB
Vue
369 lines
16 KiB
Vue
<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>
|