feat(ops/client): edit/delete/reorder subscriptions + rebate nesting

- InlineField on monthly row price (dblclick) + annual row monthly base
  price. Saves via Service Subscription.monthly_price → mirrored back
  into the UI row's actual_price; drops an audit line on the customer
  timeline.
- Delete button (confirm dialog, v-if=can('delete_records')) on both
  monthly + annual rows. Uses deleteDoc + local splice + invalidates
  location + section caches.
- display_order custom Int field on Service Subscription, persisted in
  10-step increments on drag reorder (so manual inserts have room to
  squeeze between without a full re-number pass). loadSubscriptions
  sorts by display_order first so the dispatcher-controlled order
  survives a page reload and can drive invoice print ordering later.
- Rebate rows nested visually: 32px indent + arrow glyph + lighter
  red background + smaller type + inherited red color on the inline
  price input. Matches the invoice PDF grouping dispatchers expect.
This commit is contained in:
louispaulb 2026-04-23 11:21:41 -04:00
parent dfd41ee993
commit 349f9af2da
4 changed files with 132 additions and 9 deletions

View File

@ -33,6 +33,8 @@ import 'quasar/dist/quasar.css'
import 'src/css/app.scss' import 'src/css/app.scss'
import 'src/css/tech.scss'
import createQuasarApp from './app.js' import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js' import quasarUserOptions from './quasar-user-options.js'

View File

@ -70,8 +70,11 @@ export function useClientData (deps) {
fields: ['name', 'plan_name', 'service_category', 'monthly_price', 'billing_cycle', fields: ['name', 'plan_name', 'service_category', 'monthly_price', 'billing_cycle',
'service_location', 'status', 'start_date', 'end_date', 'cancellation_date', 'service_location', 'status', 'start_date', 'end_date', 'cancellation_date',
'contract_duration', 'product_sku', 'speed_down', 'speed_up', 'contract_duration', 'product_sku', 'speed_down', 'speed_up',
'radius_user', 'device'], 'radius_user', 'device', 'display_order'],
limit: 100, orderBy: 'start_date desc, creation desc', limit: 100,
// display_order first (dispatcher-controlled), then by start date so
// newer subs float up when display_order hasn't been set yet (= 0).
orderBy: 'display_order asc, start_date desc, creation desc',
}) })
// Map Service Subscription → UI row shape (matches what useSubscriptionGroups // Map Service Subscription → UI row shape (matches what useSubscriptionGroups
@ -106,6 +109,7 @@ export function useClientData (deps) {
radius_user: doc.radius_user || '', radius_user: doc.radius_user || '',
radius_pwd: '', radius_pwd: '',
device: doc.device || '', device: doc.device || '',
display_order: Number(doc.display_order || 0),
qty: 1, qty: 1,
})) }))
} }

View File

@ -188,8 +188,35 @@ export function useSubscriptionActions (subscriptions, customer, comments, inval
} catch {} } catch {}
} }
function onSubDragChange (evt, locName) { // vuedraggable mutates the bound list in-place, so by the time this
if (evt.added || evt.removed) invalidateCache(locName) // handler fires the `items` array (passed by the template) already
// reflects the new order. We persist display_order in 10-step increments
// — leaves room for manual inserts (frappe Link-sort-by-number ugh) —
// and only PUT the rows whose position actually changed to avoid
// N writes on every drag.
async function onSubDragChange (evt, locName, items = null) {
// vuedraggable emits an object with {added|removed|moved}; old signature
// (evt, locName) with no items still works for cache-only invalidation.
if (evt.added || evt.removed || evt.moved) invalidateCache(locName)
if (!items || !items.length) return
const writes = []
for (let i = 0; i < items.length; i++) {
const want = (i + 1) * 10 // 10, 20, 30… (keeps manual gaps)
const item = items[i]
if (!item || item.display_order === want) continue
item.display_order = want // optimistic local update
writes.push(
authFetch(BASE_URL + '/api/resource/Service%20Subscription/' + encodeURIComponent(item.name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_order: want }),
}).catch(() => null)
)
}
if (writes.length) {
try { await Promise.all(writes) } catch {}
}
} }
return { return {

View File

@ -202,7 +202,7 @@
<div v-show="sectionOpen(loc.name, section.key)"> <div v-show="sectionOpen(loc.name, section.key)">
<draggable :list="section.items" :group="{ name: 'loc-subs-' + loc.name, pull: true, put: true }" item-key="name" <draggable :list="section.items" :group="{ name: 'loc-subs-' + loc.name, pull: true, put: true }" item-key="name"
handle=".drag-handle" ghost-class="sub-ghost" :animation="150" handle=".drag-handle" ghost-class="sub-ghost" :animation="150"
@change="onSubDragChange($event, loc.name, section.key)"> @change="onSubDragChange($event, loc.name, section.items)">
<template #item="{ element: sub }"> <template #item="{ element: sub }">
<div class="sub-row clickable-row" :class="{ 'sub-rebate': isRebate(sub), 'sub-cancelled': sub.status === 'Cancelled' }"> <div class="sub-row clickable-row" :class="{ 'sub-rebate': isRebate(sub), 'sub-cancelled': sub.status === 'Cancelled' }">
<div class="row items-center no-wrap"> <div class="row items-center no-wrap">
@ -211,7 +211,21 @@
<div class="row items-center no-wrap q-gutter-x-sm"> <div class="row items-center no-wrap q-gutter-x-sm">
<code class="sub-sku">{{ sub.name }}</code> <code class="sub-sku">{{ sub.name }}</code>
<span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">{{ subMainLabel(sub) }}</span> <span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">{{ subMainLabel(sub) }}</span>
<span class="sub-price" :class="{ 'text-red': isRebate(sub), 'text-weight-bold': !isRebate(sub) }">{{ formatMoney(sub.actual_price || 0) }}</span> <!-- Inline price edit: double-click to edit. Negative values kept as-is for rebates. -->
<span class="sub-price-wrap" @click.stop @dblclick.stop
:title="can('edit_records') ? 'Double-clic pour modifier le prix' : ''">
<InlineField :value="sub.actual_price || 0"
field="monthly_price" doctype="Service Subscription" :docname="sub.name"
type="number" :formatter="formatMoney"
:readonly="!can('edit_records')"
@saved="onSubPriceSaved(sub, $event)">
<template #display="{ displayValue }">
<span class="sub-price" :class="{ 'text-red': isRebate(sub), 'text-weight-bold': !isRebate(sub) }">
{{ displayValue }}
</span>
</template>
</InlineField>
</span>
</div> </div>
</div> </div>
<div class="col-auto sub-actions"> <div class="col-auto sub-actions">
@ -229,6 +243,10 @@
@click.stop="toggleSubStatus(sub)" :loading="subSaving === sub.name + ':status'"> @click.stop="toggleSubStatus(sub)" :loading="subSaving === sub.name + ':status'">
<q-tooltip>{{ sub.status === 'Cancelled' ? 'Réactiver' : 'Désactiver' }}</q-tooltip> <q-tooltip>{{ sub.status === 'Cancelled' ? 'Réactiver' : 'Désactiver' }}</q-tooltip>
</q-btn> </q-btn>
<q-btn v-if="can('delete_records')" flat dense round size="xs" icon="delete_outline" color="red-4"
@click.stop="confirmDeleteSub(sub)">
<q-tooltip>Supprimer définitivement</q-tooltip>
</q-btn>
</div> </div>
</div> </div>
</div> </div>
@ -256,7 +274,15 @@
<span class="sub-freq annual">A</span> <span class="sub-freq annual">A</span>
<span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">{{ subMainLabel(sub) }}</span> <span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">{{ subMainLabel(sub) }}</span>
<span class="sub-price" :class="{ 'text-red': isRebate(sub), 'text-weight-bold': !isRebate(sub) }">{{ formatMoney(annualPrice(sub)) }}</span> <span class="sub-price" :class="{ 'text-red': isRebate(sub), 'text-weight-bold': !isRebate(sub) }">{{ formatMoney(annualPrice(sub)) }}</span>
<span class="text-caption text-grey-5" style="white-space:nowrap">({{ formatMoney(sub.actual_price || 0) }}/m&times;12)</span> <!-- Inline edit on the per-month base price annual total recomputes from it. -->
<span class="sub-price-wrap text-caption text-grey-5" style="white-space:nowrap" @click.stop @dblclick.stop
:title="can('edit_records') ? 'Double-clic pour modifier le prix mensuel' : ''">
(<InlineField :value="sub.actual_price || 0"
field="monthly_price" doctype="Service Subscription" :docname="sub.name"
type="number" :formatter="formatMoney"
:readonly="!can('edit_records')"
@saved="onSubPriceSaved(sub, $event)" />/m&times;12)
</span>
</div> </div>
<div class="row items-center q-pl-lg q-mt-xs q-gutter-x-md"> <div class="row items-center q-pl-lg q-mt-xs q-gutter-x-md">
<div class="sub-meta"> <div class="sub-meta">
@ -287,6 +313,10 @@
@click.stop="toggleSubStatus(sub)" :loading="subSaving === sub.name + ':status'"> @click.stop="toggleSubStatus(sub)" :loading="subSaving === sub.name + ':status'">
<q-tooltip>{{ sub.status === 'Cancelled' ? 'Réactiver' : 'Désactiver' }}</q-tooltip> <q-tooltip>{{ sub.status === 'Cancelled' ? 'Réactiver' : 'Désactiver' }}</q-tooltip>
</q-btn> </q-btn>
<q-btn v-if="can('delete_records')" flat dense round size="xs" icon="delete_outline" color="red-4"
@click.stop="confirmDeleteSub(sub)">
<q-tooltip>Supprimer définitivement</q-tooltip>
</q-btn>
</div> </div>
</div> </div>
</div> </div>
@ -987,7 +1017,7 @@ const {
const { const {
subSaving, togglingRecurring, subSaving, togglingRecurring,
toggleSubStatus, toggleFrequency, toggleRecurring, toggleSubStatus, toggleFrequency, toggleRecurring,
toggleRecurringModal, saveSubField, onSubDragChange, toggleRecurringModal, saveSubField, logSubChange, onSubDragChange,
} = useSubscriptionActions(subscriptions, customer, comments, invalidateCache) } = useSubscriptionActions(subscriptions, customer, comments, invalidateCache)
const { onNoteAdded } = useCustomerNotes(comments, customer) const { onNoteAdded } = useCustomerNotes(comments, customer)
@ -1170,6 +1200,46 @@ async function createService () {
} }
} }
// Inline price edit callback the InlineField has already persisted
// `monthly_price` to ERPNext; we mirror it into the UI row
// (`actual_price`), invalidate the location cache so the monthly total
// recomputes, and drop an audit line.
function onSubPriceSaved (sub, evt) {
const oldPrice = Number(sub.actual_price || 0)
const newPrice = Number(evt.value || 0)
sub.actual_price = newPrice
if (sub.service_location) invalidateCache(sub.service_location)
if (oldPrice !== newPrice) {
logSubChange(sub, `Prix modifié: ${formatMoney(oldPrice)}${formatMoney(newPrice)}`)
}
}
// Delete a subscription row. Uses the Quasar Dialog for a confirm step
// because a click-through on a red bin would be too trigger-happy.
async function confirmDeleteSub (sub) {
const label = sub.plan_name || sub.item_name || sub.name
$q.dialog({
title: 'Supprimer ce service ?',
message: `<div><code>${sub.name}</code> — ${label} (${formatMoney(sub.actual_price)})</div>`
+ '<div class="text-caption text-grey-6 q-mt-sm">L\'historique de facturation reste intact.</div>',
html: true,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'red', label: 'Supprimer', unelevated: true, icon: 'delete' },
persistent: true,
}).onOk(async () => {
try {
await deleteDoc('Service Subscription', sub.name)
const idx = subscriptions.value.findIndex(s => s.name === sub.name)
if (idx >= 0) subscriptions.value.splice(idx, 1)
if (sub.service_location) invalidateCache(sub.service_location)
invalidateAll()
Notify.create({ type: 'positive', message: `${label} supprimé`, position: 'top', timeout: 2500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'Suppression impossible'), position: 'top', timeout: 4000 })
}
})
}
function onDispatchCreated (job) { modalDispatchJobs.value.push(job) } function onDispatchCreated (job) { modalDispatchJobs.value.push(job) }
function formatTimeAgo (dateStr) { function formatTimeAgo (dateStr) {
@ -1410,13 +1480,33 @@ onMounted(() => loadCustomer(props.id))
.sub-section-subtotal { margin-left: auto; font-size: 0.8rem; font-weight: 700; color: var(--ops-accent); } .sub-section-subtotal { margin-left: auto; font-size: 0.8rem; font-weight: 700; color: var(--ops-accent); }
.sub-ghost { opacity: 0.4; background: #e0f2fe; border-radius: 6px; } .sub-ghost { opacity: 0.4; background: #e0f2fe; border-radius: 6px; }
.drag-handle:hover { color: #64748b !important; } .drag-handle:hover { color: #64748b !important; }
.sub-rebate { padding-left: 16px; border-left: 2px solid #fca5a5; margin-left: 4px; } /* Rebate rows: nested visually under the service above via stronger indent +
smaller type. The left border + arrow-glyph treatment mirrors what shows up
on the invoice PDF so dispatchers can preview the print order. */
.sub-rebate {
padding-left: 32px; border-left: 2px solid #fca5a5; margin-left: 12px;
background: #fef2f2; border-radius: 0 6px 6px 0;
font-size: 0.85rem;
position: relative;
}
.sub-rebate::before {
content: '↳'; position: absolute; left: 12px; top: 6px;
font-size: 0.75rem; color: #ef4444; opacity: 0.7;
}
.sub-rebate .sub-sku { font-size: 0.55rem; width: 64px; min-width: 64px; max-width: 64px; }
.sub-rebate .sub-price { font-size: 0.8rem; }
.sub-cancelled { opacity: 0.5; text-decoration: line-through; } .sub-cancelled { opacity: 0.5; text-decoration: line-through; }
.sub-actions { display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; } .sub-actions { display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; }
.sub-row:hover .sub-actions { opacity: 1; } .sub-row:hover .sub-actions { opacity: 1; }
.sub-sku { font-size: 0.6rem; font-weight: 400; color: #94a3b8; background: none; padding: 0; white-space: nowrap; display: inline-block; width: 72px; min-width: 72px; max-width: 72px; text-align: left; overflow: hidden; text-overflow: ellipsis; } .sub-sku { font-size: 0.6rem; font-weight: 400; color: #94a3b8; background: none; padding: 0; white-space: nowrap; display: inline-block; width: 72px; min-width: 72px; max-width: 72px; text-align: left; overflow: hidden; text-overflow: ellipsis; }
.sub-freq { font-size: 0.65rem; font-weight: 700; color: #9ca3af; &.annual { color: #92400e; background: #fef3c7; } background: #f1f5f9; border-radius: 3px; padding: 1px 4px; } .sub-freq { font-size: 0.65rem; font-weight: 700; color: #9ca3af; &.annual { color: #92400e; background: #fef3c7; } background: #f1f5f9; border-radius: 3px; padding: 1px 4px; }
.sub-price { font-size: 0.85rem; white-space: nowrap; } .sub-price { font-size: 0.85rem; white-space: nowrap; }
/* InlineField wraps the price in a <span>/<input>; force children to inherit
the row-level color so `.text-red` on the display span works for rebates
and the edit input picks up the same accent. */
.sub-price-wrap { display: inline-flex; align-items: center; }
.sub-price-wrap :deep(input) { color: inherit; text-align: right; max-width: 80px; }
.sub-rebate .sub-price-wrap, .sub-rebate .sub-price-wrap :deep(*) { color: #dc2626; }
.sub-meta { display: flex; align-items: center; gap: 2px; } .sub-meta { display: flex; align-items: center; gap: 2px; }
.ellipsis-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ellipsis-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ticket-row { padding: 4px 0; &:not(:last-child) { border-bottom: 1px solid #e2e8f0; } } .ticket-row { padding: 4px 0; &:not(:last-child) { border-bottom: 1px solid #e2e8f0; } }