fix(ops/client): cancelled subs no longer inflate monthly total + Lieu link in-app

Three connected dispatcher-facing issues from C-LPB4 audit:

1. **Monthly total was wrong on customer cards.** Section subtotal and
   `locSubsMonthlyTotal` summed `actual_price` for ALL subscriptions
   regardless of status, so cancelled rows (rendered with strikethrough)
   still pumped up the displayed billing figure. C-LPB4 showed
   "Total mensuel: 86,10$" computed as `196.05 - 109.95 = 86.10`,
   where 196.05 included 3 cancelled internet plans (Megafibre 80,
   TEST-E2E-FTTH, FTTH100 — all struck through in the UI). Real
   active monthly is 5.00$ (109.95 active + 5 frais réseau − 109.95
   loyalty rebate). Fixed both `sectionTotal` and `locSubsMonthlyTotal`
   /`locSubsAnnualTotal` to filter on `status === 'Active'`.

2. **"Lieu" link from a dispatch task pointed to ERPNext desk** which
   shows a raw doctype form (no abonnements, no totals, no contacts —
   just the bare fields). Now points in-app to
   `#/clients/<customer>?location=<SL>`. ClientDetailPage reads the
   query string on mount and:
     • scrolls the matching `loc-card` into view
     • pulses an indigo halo around it for ~2s so the rep finds it
       immediately even when the customer has many service locations.

3. **The shipping/billing distinction was invisible** on the customer
   page. Added an "Adresses de livraison" badge next to the "Lieux de
   service" section title — clarifies that this section IS the
   shipping address, distinct from the (future) billing address that
   will live on the Customer record. Cosmetic for now; the data
   migration to formalize that distinction is the next step.

These three round out the C-LPB4 audit triggered by the wrong
mapbox-pin location: now the customer card on the dispatcher's
screen shows correct totals, the dispatch link drops them right at
the spot they're trying to reach, and the role of each address-bearing
record is named explicitly.
This commit is contained in:
louispaulb 2026-05-08 11:21:18 -04:00
parent ab7644e6de
commit 1186e50bbe
4 changed files with 75 additions and 17 deletions

View File

@ -73,8 +73,16 @@ export function subSubLabel (sub) {
return ''
}
// Only Active subs contribute to the displayed monthly total. Cancelled/
// Suspended/Pending rows are still rendered (with strikethrough) for
// audit visibility but they shouldn't inflate the number the rep
// communicates to the customer. Was a real bug on C-LPB4: 5 internet
// rows summed to 196.05 because the 3 cancelled (Megafibre 80, TEST,
// FTTH100) were included in the math even though shown struck-through.
const isActiveBilling = sub => (sub.status || 'Active') === 'Active'
export function sectionTotal (items) {
return items.reduce((s, sub) => s + parseFloat(sub.actual_price || 0), 0)
return items.reduce((s, sub) => isActiveBilling(sub) ? s + parseFloat(sub.actual_price || 0) : s, 0)
}
export function annualPrice (sub) {
@ -102,11 +110,12 @@ export function useSubscriptionGroups (subscriptions) {
}
function locSubsMonthlyTotal (locName) {
return locSubsMonthly(locName).reduce((sum, s) => sum + parseFloat(s.actual_price || 0), 0)
// Same active-only filter as sectionTotal — keep them in sync.
return locSubsMonthly(locName).reduce((sum, s) => isActiveBilling(s) ? sum + parseFloat(s.actual_price || 0) : sum, 0)
}
function locSubsAnnualTotal (locName) {
return locSubsAnnual(locName).reduce((sum, s) => sum + annualPrice(s), 0)
return locSubsAnnual(locName).reduce((sum, s) => isActiveBilling(s) ? sum + annualPrice(s) : sum, 0)
}
function locSubsSections (locName, freq) {

View File

@ -52,11 +52,13 @@ const onDeleteTag = inject('onDeleteTag')
</div>
<div v-if="panel.data?.job?.serviceLocation" class="sb-rp-field">
<span class="sb-rp-lbl">Lieu</span>
<!-- Frappe v14+ desk URL: /app/<slug>/<name>, slug = lowercase
with hyphens. The legacy /desk/<DocType>/<name> format with
spaces returns "Page not found" on v16. -->
<a class="sb-rp-link" target="_blank" rel="noopener"
:href="'/app/service-location/' + panel.data.job.serviceLocation">
<!-- Lien in-app vers la fiche client, anchored on the SL.
ClientDetailPage reads ?location= from the query string
on mount and scrolls to that Service Location section.
Beats the bare ERPNext desk view which is unfriendly
(no abonnements, no totaux, just the raw doctype form). -->
<a class="sb-rp-link"
:href="'#/clients/' + panel.data.job.customer + '?location=' + panel.data.job.serviceLocation">
<code>{{ panel.data.job.serviceLocation }}</code>
<span class="sb-rp-link-icon"></span>
</a>

View File

@ -15,16 +15,25 @@
<q-expansion-item v-model="sectionsOpen.locations" header-class="section-header" class="q-mb-sm">
<template #header>
<div class="section-title" style="font-size:1rem;width:100%">
<q-icon name="location_on" size="20px" class="q-mr-xs" />
Lieux de service ({{ locations.length }})
<div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center;gap:6px">
<q-icon name="location_on" size="20px" />
<span>Lieux de service ({{ locations.length }})</span>
<!-- Subtle clarifier: chaque lieu = adresse de livraison
= endroit le tech va = l'ONT vit. Distinct de
l'adresse de facturation portée par Customer. -->
<q-badge color="indigo-1" text-color="indigo-7" class="q-ml-sm" style="font-size:0.62rem;font-weight:600">
<q-icon name="local_shipping" size="11px" class="q-mr-xs" />
Adresses de livraison
</q-badge>
</div>
</template>
<div v-if="!locations.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucun lieu de service</div>
<div v-for="loc in sortedLocations" :key="loc.name" class="ops-card q-mb-md loc-card"
:class="{ 'loc-inactive': !locHasSubs(loc.name) }">
<div v-for="loc in sortedLocations" :key="loc.name"
:id="'loc-' + loc.name"
class="ops-card q-mb-md loc-card"
:class="{ 'loc-inactive': !locHasSubs(loc.name), 'loc-highlight': highlightLoc === loc.name }">
<div class="loc-header" :style="!locHasSubs(loc.name) ? 'cursor:pointer' : ''"
@click="!locHasSubs(loc.name) && toggleLocCollapse(loc.name)">
<div class="row items-center">
@ -955,6 +964,7 @@
<script setup>
import { ref, computed, onMounted, reactive, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Notify, useQuasar } from 'quasar'
import draggable from 'vuedraggable'
import { deleteDoc, createDoc, listDocs } from 'src/api/erp'
@ -1552,7 +1562,32 @@ function confirmRefund (pe) {
}
watch(() => props.id, (newId) => { if (newId) loadCustomer(newId) })
onMounted(() => loadCustomer(props.id))
// ?location=LOC-XXX query param: when arriving from a dispatch link
// we scroll to that Service Location and pulse-highlight it for 2 s
// so the rep doesn't have to find it among potentially many SLs.
const highlightLoc = ref(null)
const route = useRoute()
async function focusLocationFromQuery () {
const target = route.query?.location
if (!target) return
highlightLoc.value = target
// Wait for the customer load to finish + DOM to render the cards.
let tries = 0
while (tries < 30 && !document.getElementById('loc-' + target)) {
await new Promise(r => setTimeout(r, 100))
tries++
}
const el = document.getElementById('loc-' + target)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
setTimeout(() => { highlightLoc.value = null }, 2200)
}
onMounted(async () => {
await loadCustomer(props.id)
focusLocationFromQuery()
})
watch(() => route.query?.location, () => focusLocationFromQuery())
</script>
<style lang="scss" scoped>
@ -1571,9 +1606,21 @@ onMounted(() => loadCustomer(props.id))
.editable-row .q-field :deep(.q-field__native):focus,
.editable-row .q-field :deep(.q-field__input):focus { background: #e0f2fe; border-radius: 4px; }
.loc-card { border-left: 3px solid var(--ops-accent); }
.loc-card { border-left: 3px solid var(--ops-accent); transition: box-shadow 0.3s, border-left-color 0.3s; }
.loc-card.loc-inactive { border-left-color: #cbd5e1; opacity: 0.7; }
.loc-card.loc-inactive:hover { opacity: 0.9; }
/* loc-highlight: 2-second pulse when the page is opened from a
dispatch link with ?location=LOC-XXX, so the rep visually finds
the right Service Location among several. */
.loc-card.loc-highlight {
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.45), 0 4px 18px rgba(99, 102, 241, 0.2);
border-left-color: #6366f1;
animation: loc-flash 1.4s ease-out;
}
@keyframes loc-flash {
0% { box-shadow: 0 0 0 6px rgba(99, 102, 241, 0.6), 0 6px 26px rgba(99, 102, 241, 0.4); }
100% { box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.45), 0 4px 18px rgba(99, 102, 241, 0.2); }
}
.loc-header { padding-bottom: 8px; border-bottom: 1px solid var(--ops-border); }
.info-block { background: #f8fafc; border-radius: 8px; padding: 10px 12px; }
.info-block-title { font-size: 0.75rem; font-weight: 700; color: var(--ops-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }

View File

@ -1676,8 +1676,8 @@ onUnmounted(() => {
:href="'#/clients/' + ctxMenu.job.customer" @click="closeCtxMenu()">
👤 Voir la fiche client ({{ ctxMenu.job.customer }})
</a>
<a v-if="ctxMenu?.job?.serviceLocation" class="sb-ctx-item sb-ctx-link" target="_blank" rel="noopener"
:href="'/app/service-location/' + ctxMenu.job.serviceLocation" @click="closeCtxMenu()">
<a v-if="ctxMenu?.job?.serviceLocation && ctxMenu?.job?.customer" class="sb-ctx-item sb-ctx-link"
:href="'#/clients/' + ctxMenu.job.customer + '?location=' + ctxMenu.job.serviceLocation" @click="closeCtxMenu()">
🏠 Lieu de service ({{ ctxMenu.job.serviceLocation }})
</a>
<div class="sb-ctx-sep"></div>