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:
parent
dfd41ee993
commit
349f9af2da
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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×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×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; } }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user