gigafibre-fsm/apps/ops/src/components/shared/detail-sections/ServiceContractDetail.vue
louispaulb 218f6fc7b1 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.
2026-04-23 14:46:34 -04:00

369 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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