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 '' 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) { 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) { export function annualPrice (sub) {
@ -102,11 +110,12 @@ export function useSubscriptionGroups (subscriptions) {
} }
function locSubsMonthlyTotal (locName) { 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) { 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) { function locSubsSections (locName, freq) {

View File

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

View File

@ -15,16 +15,25 @@
<q-expansion-item v-model="sectionsOpen.locations" header-class="section-header" class="q-mb-sm"> <q-expansion-item v-model="sectionsOpen.locations" header-class="section-header" class="q-mb-sm">
<template #header> <template #header>
<div class="section-title" style="font-size:1rem;width:100%"> <div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center;gap:6px">
<q-icon name="location_on" size="20px" class="q-mr-xs" /> <q-icon name="location_on" size="20px" />
Lieux de service ({{ locations.length }}) <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> </div>
</template> </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-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" <div v-for="loc in sortedLocations" :key="loc.name"
:class="{ 'loc-inactive': !locHasSubs(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' : ''" <div class="loc-header" :style="!locHasSubs(loc.name) ? 'cursor:pointer' : ''"
@click="!locHasSubs(loc.name) && toggleLocCollapse(loc.name)"> @click="!locHasSubs(loc.name) && toggleLocCollapse(loc.name)">
<div class="row items-center"> <div class="row items-center">
@ -955,6 +964,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, reactive, watch } from 'vue' import { ref, computed, onMounted, reactive, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Notify, useQuasar } from 'quasar' import { Notify, useQuasar } from 'quasar'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { deleteDoc, createDoc, listDocs } from 'src/api/erp' import { deleteDoc, createDoc, listDocs } from 'src/api/erp'
@ -1552,7 +1562,32 @@ function confirmRefund (pe) {
} }
watch(() => props.id, (newId) => { if (newId) loadCustomer(newId) }) 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> </script>
<style lang="scss" scoped> <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__native):focus,
.editable-row .q-field :deep(.q-field__input):focus { background: #e0f2fe; border-radius: 4px; } .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 { border-left-color: #cbd5e1; opacity: 0.7; }
.loc-card.loc-inactive:hover { opacity: 0.9; } .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); } .loc-header { padding-bottom: 8px; border-bottom: 1px solid var(--ops-border); }
.info-block { background: #f8fafc; border-radius: 8px; padding: 10px 12px; } .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; } .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()"> :href="'#/clients/' + ctxMenu.job.customer" @click="closeCtxMenu()">
👤 Voir la fiche client ({{ ctxMenu.job.customer }}) 👤 Voir la fiche client ({{ ctxMenu.job.customer }})
</a> </a>
<a v-if="ctxMenu?.job?.serviceLocation" class="sb-ctx-item sb-ctx-link" target="_blank" rel="noopener" <a v-if="ctxMenu?.job?.serviceLocation && ctxMenu?.job?.customer" class="sb-ctx-item sb-ctx-link"
:href="'/app/service-location/' + ctxMenu.job.serviceLocation" @click="closeCtxMenu()"> :href="'#/clients/' + ctxMenu.job.customer + '?location=' + ctxMenu.job.serviceLocation" @click="closeCtxMenu()">
🏠 Lieu de service ({{ ctxMenu.job.serviceLocation }}) 🏠 Lieu de service ({{ ctxMenu.job.serviceLocation }})
</a> </a>
<div class="sb-ctx-sep"></div> <div class="sb-ctx-sep"></div>