diff --git a/apps/ops/.quasar/client-entry.js b/apps/ops/.quasar/client-entry.js
index de61936..8c0244d 100644
--- a/apps/ops/.quasar/client-entry.js
+++ b/apps/ops/.quasar/client-entry.js
@@ -33,6 +33,8 @@ import 'quasar/dist/quasar.css'
import 'src/css/app.scss'
+import 'src/css/tech.scss'
+
import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js'
diff --git a/apps/ops/src/composables/useClientData.js b/apps/ops/src/composables/useClientData.js
index da8f017..cc09770 100644
--- a/apps/ops/src/composables/useClientData.js
+++ b/apps/ops/src/composables/useClientData.js
@@ -70,8 +70,11 @@ export function useClientData (deps) {
fields: ['name', 'plan_name', 'service_category', 'monthly_price', 'billing_cycle',
'service_location', 'status', 'start_date', 'end_date', 'cancellation_date',
'contract_duration', 'product_sku', 'speed_down', 'speed_up',
- 'radius_user', 'device'],
- limit: 100, orderBy: 'start_date desc, creation desc',
+ 'radius_user', 'device', 'display_order'],
+ 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
@@ -106,6 +109,7 @@ export function useClientData (deps) {
radius_user: doc.radius_user || '',
radius_pwd: '',
device: doc.device || '',
+ display_order: Number(doc.display_order || 0),
qty: 1,
}))
}
diff --git a/apps/ops/src/composables/useSubscriptionActions.js b/apps/ops/src/composables/useSubscriptionActions.js
index c153772..a18638d 100644
--- a/apps/ops/src/composables/useSubscriptionActions.js
+++ b/apps/ops/src/composables/useSubscriptionActions.js
@@ -188,8 +188,35 @@ export function useSubscriptionActions (subscriptions, customer, comments, inval
} catch {}
}
- function onSubDragChange (evt, locName) {
- if (evt.added || evt.removed) invalidateCache(locName)
+ // vuedraggable mutates the bound list in-place, so by the time this
+ // 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 {
diff --git a/apps/ops/src/pages/ClientDetailPage.vue b/apps/ops/src/pages/ClientDetailPage.vue
index ef21f61..54f23d3 100644
--- a/apps/ops/src/pages/ClientDetailPage.vue
+++ b/apps/ops/src/pages/ClientDetailPage.vue
@@ -202,7 +202,7 @@
+ @change="onSubDragChange($event, loc.name, section.items)">
@@ -211,7 +211,21 @@
{{ sub.name }}
{{ subMainLabel(sub) }}
- {{ formatMoney(sub.actual_price || 0) }}
+
+
+
+
+
+ {{ displayValue }}
+
+
+
+
@@ -229,6 +243,10 @@
@click.stop="toggleSubStatus(sub)" :loading="subSaving === sub.name + ':status'">
{{ sub.status === 'Cancelled' ? 'Réactiver' : 'Désactiver' }}
+
+ Supprimer définitivement
+
@@ -256,7 +274,15 @@
A
{{ subMainLabel(sub) }}
{{ formatMoney(annualPrice(sub)) }}
- ({{ formatMoney(sub.actual_price || 0) }}/m×12)
+
+
+ (/m×12)
+
@@ -287,6 +313,10 @@
@click.stop="toggleSubStatus(sub)" :loading="subSaving === sub.name + ':status'">
{{ sub.status === 'Cancelled' ? 'Réactiver' : 'Désactiver' }}
+
+ Supprimer définitivement
+
@@ -987,7 +1017,7 @@ const {
const {
subSaving, togglingRecurring,
toggleSubStatus, toggleFrequency, toggleRecurring,
- toggleRecurringModal, saveSubField, onSubDragChange,
+ toggleRecurringModal, saveSubField, logSubChange, onSubDragChange,
} = useSubscriptionActions(subscriptions, customer, comments, invalidateCache)
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: `${sub.name} — ${label} (${formatMoney(sub.actual_price)})
`
+ + 'L\'historique de facturation reste intact.
',
+ 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 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-ghost { opacity: 0.4; background: #e0f2fe; border-radius: 6px; }
.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-actions { display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; }
.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-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; }
+/* InlineField wraps the price in a /; 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; }
.ellipsis-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ticket-row { padding: 4px 0; &:not(:last-child) { border-bottom: 1px solid #e2e8f0; } }