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:
parent
ab7644e6de
commit
1186e50bbe
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 où le tech va = où 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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user