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.
125 lines
5.5 KiB
Vue
125 lines
5.5 KiB
Vue
<template>
|
|
<q-dialog :model-value="open" @update:model-value="$emit('update:open', $event)" position="right" full-height>
|
|
<q-card style="width:600px;max-width:90vw" class="column no-wrap">
|
|
<!-- Header -->
|
|
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
|
|
<div class="col">
|
|
<div class="text-subtitle1 text-weight-bold">
|
|
<slot name="title-prefix" />
|
|
{{ title }}
|
|
</div>
|
|
<div class="text-caption text-grey-6">
|
|
{{ docName }}
|
|
<slot name="title-suffix" />
|
|
</div>
|
|
</div>
|
|
<slot name="header-actions" />
|
|
<q-btn flat round dense icon="open_in_new" @click="openExternal(erpLinkUrl)" class="q-mr-xs" />
|
|
<q-btn flat round dense icon="close" @click="$emit('update:open', false)" />
|
|
</q-card-section>
|
|
|
|
<!-- Loading -->
|
|
<q-card-section v-if="loading" class="flex flex-center" style="min-height:200px">
|
|
<q-spinner size="32px" color="indigo-6" />
|
|
</q-card-section>
|
|
|
|
<!-- Content -->
|
|
<q-card-section v-else-if="doc" class="col q-pt-sm" style="overflow-y:auto">
|
|
<component :is="sectionComponent" v-if="sectionComponent"
|
|
:doc="doc" :doc-name="docName" :title="title"
|
|
:comments="comments" :comms="comms" :files="files"
|
|
:dispatch-jobs="dispatchJobs"
|
|
@navigate="(...a) => $emit('navigate', ...a)"
|
|
@reply-sent="(...a) => $emit('reply-sent', ...a)"
|
|
@save-field="(...a) => $emit('save-field', ...a)"
|
|
@toggle-recurring="(...a) => $emit('toggle-recurring', ...a)"
|
|
@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)"
|
|
/>
|
|
|
|
<!-- Generic fallback -->
|
|
<template v-else>
|
|
<div class="modal-field-grid">
|
|
<div v-for="(val, key) in docFields" :key="key" class="mf">
|
|
<span class="mf-label">{{ key }}</span>
|
|
<span v-if="typeof val === 'number'">{{ val }}</span>
|
|
<span v-else>{{ val || '---' }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed } from 'vue'
|
|
import { erpLink } from 'src/composables/useFormatters'
|
|
|
|
import InvoiceDetail from './detail-sections/InvoiceDetail.vue'
|
|
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({
|
|
open: Boolean,
|
|
loading: Boolean,
|
|
doctype: String,
|
|
docName: String,
|
|
title: String,
|
|
doc: Object,
|
|
comments: { type: Array, default: () => [] },
|
|
comms: { type: Array, default: () => [] },
|
|
files: { type: Array, default: () => [] },
|
|
docFields: { type: Object, default: () => ({}) },
|
|
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', 'contract-terminated'])
|
|
|
|
function onDeleted (docName) {
|
|
emit('update:open', false)
|
|
emit('deleted', docName)
|
|
}
|
|
|
|
const erpLinkUrl = computed(() => erpLink(props.doctype, props.docName))
|
|
const sectionComponent = computed(() => SECTION_MAP[props.doctype] || null)
|
|
|
|
function openExternal (url) { window.open(url, '_blank') }
|
|
</script>
|
|
|
|
<style scoped>
|
|
.modal-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; }
|
|
.mf { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; font-size: 0.875rem; border-bottom: 1px solid #f1f5f9; }
|
|
.mf-label { font-size: 0.75rem; font-weight: 600; color: #6b7280; min-width: 80px; flex-shrink: 0; }
|
|
.info-block-title { font-size: 0.75rem; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
|
|
.modal-desc { font-size: 0.85rem; line-height: 1.5; background: #f8fafc; border-radius: 8px; padding: 10px 12px; max-height: 300px; overflow-y: auto; }
|
|
.info-row { display: flex; align-items: center; gap: 8px; }
|
|
.comm-row { padding: 10px 0; border-bottom: 1px solid #e2e8f0; }
|
|
.comm-row:last-child { border-bottom: none; }
|
|
.thread-msg { border-left: 3px solid #e2e8f0; padding: 8px 0 8px 12px; margin-bottom: 2px; }
|
|
.thread-msg:hover { border-left-color: #6366f1; }
|
|
.thread-header { display: flex; align-items: center; font-size: 0.78rem; color: #374151; margin-bottom: 4px; }
|
|
.thread-body { font-size: 0.84rem; line-height: 1.5; color: #1f2937; }
|
|
.thread-body :deep(p) { margin: 0 0 4px; }
|
|
.thread-body :deep(hr) { border: none; border-top: 1px solid #e5e7eb; margin: 6px 0; }
|
|
.thread-body :deep(br + br) { display: none; }
|
|
.reply-box { border-top: 1px solid #e2e8f0; padding-top: 12px; }
|
|
</style>
|