domma-cms 1.6.56 → 1.6.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/css/calculator.css +1 -1
- package/admin/js/lib/analytics-shape.js +1 -1
- package/admin/js/lib/calculator-engine.js +1 -1
- package/admin/js/lib/calculator.js +22 -10
- package/admin/js/lib/form-editor-arrange.js +1 -1
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/notes-arrange.js +1 -1
- package/admin/js/lib/search-settings-arrange.js +1 -1
- package/admin/js/lib/seo-arrange.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +4 -4
- package/admin/js/templates/analytics.html +26 -14
- package/admin/js/templates/contacts.html +26 -5
- package/admin/js/templates/form-editor.html +2 -0
- package/admin/js/templates/notes.html +11 -8
- package/admin/js/templates/search.html +9 -3
- package/admin/js/templates/seo.html +23 -17
- package/admin/js/templates/todo.html +18 -14
- package/admin/js/views/analytics.js +10 -10
- package/admin/js/views/contacts.js +47 -47
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/notes.js +6 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +43 -43
- package/admin/js/views/todo.js +8 -8
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-kit.js +364 -0
- package/plugins/_lib/admin/mail/reader-view.js +1009 -161
- package/plugins/_lib/admin/mail/when-closed.js +33 -3
- package/plugins/_lib/mail/readRoutes.js +48 -10
- package/plugins/_lib/mail/unreadBadge.js +4 -1
- package/plugins/blog/CLAUDE.md +14 -0
- package/plugins/blog/admin/css/index.css +5 -0
- package/plugins/blog/admin/lib/drafts.js +95 -0
- package/plugins/blog/admin/templates/blog.html +1 -1
- package/plugins/blog/admin/templates/categories.html +1 -1
- package/plugins/blog/admin/templates/comments.html +1 -1
- package/plugins/blog/admin/templates/post-editor.html +5 -1
- package/plugins/blog/admin/views/blog.js +44 -19
- package/plugins/blog/admin/views/categories.js +138 -20
- package/plugins/blog/admin/views/comments.js +32 -23
- package/plugins/blog/admin/views/kit.js +232 -22
- package/plugins/blog/admin/views/post-editor.js +348 -178
- package/plugins/blog/admin/views/settings.js +17 -3
- package/plugins/blog/plugin.js +1 -1
- package/plugins/blog/plugin.json +7 -7
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/drafts.test.js +50 -0
- package/plugins/booking/CLAUDE.md +13 -0
- package/plugins/booking/admin/lib/drafts.js +139 -0
- package/plugins/booking/admin/lib/kit.js +246 -25
- package/plugins/booking/admin/views/booking.js +11 -2
- package/plugins/booking/admin/views/bookings.js +7 -2
- package/plugins/booking/admin/views/diary.js +13 -6
- package/plugins/booking/admin/views/panels.js +85 -12
- package/plugins/booking/admin/views/people.js +41 -2
- package/plugins/booking/admin/views/services.js +45 -3
- package/plugins/booking/admin/views/settings.js +22 -2
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/admin-drafts.test.js +67 -0
- package/plugins/feedback/CLAUDE.md +12 -0
- package/plugins/feedback/admin/css/index.css +5 -0
- package/plugins/feedback/admin/lib/drafts.js +145 -0
- package/plugins/feedback/admin/lib/kit.js +161 -5
- package/plugins/feedback/admin/lib/settings.js +46 -28
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/admin/views/inbox.js +110 -46
- package/plugins/feedback/admin/views/send.js +174 -83
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/feedback/tests/drafts.test.js +93 -0
- package/plugins/free-tier.lock.json +75 -63
- package/plugins/security/CLAUDE.md +9 -0
- package/plugins/security/admin/lib/screen-state.js +120 -0
- package/plugins/security/admin/templates/security.html +15 -10
- package/plugins/security/admin/views/security.js +270 -132
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/screen-state.test.js +69 -0
- package/plugins/shopping-cart/CLAUDE.md +13 -0
- package/plugins/shopping-cart/admin/css/index.css +21 -5
- package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
- package/plugins/shopping-cart/admin/lib/kit.js +136 -28
- package/plugins/shopping-cart/admin/views/orders.js +117 -36
- package/plugins/shopping-cart/admin/views/overview.js +38 -8
- package/plugins/shopping-cart/admin/views/products.js +317 -68
- package/plugins/shopping-cart/admin/views/sales.js +21 -3
- package/plugins/shopping-cart/admin/views/settings.js +62 -3
- package/plugins/shopping-cart/admin/views/shop.js +18 -3
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/tests/accounts.test.js +11 -0
- package/plugins/shopping-cart/tests/drafts.test.js +118 -0
- package/plugins/support/CLAUDE.md +14 -0
- package/plugins/support/admin/css/index.css +10 -0
- package/plugins/support/admin/lib/drafts.js +162 -0
- package/plugins/support/admin/lib/kit.js +156 -16
- package/plugins/support/admin/lib/settings.js +52 -29
- package/plugins/support/admin/views/desk.js +114 -33
- package/plugins/support/admin/views/inbox.js +85 -26
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/plugins/support/tests/drafts.test.js +92 -0
- package/server/routes/api/analytics.js +5 -1
- package/server/routes/api/contacts.js +42 -0
- package/server/routes/api/forms.js +8 -0
- package/server/routes/api/seo.js +4 -1
- package/server/routes/api/todo.js +50 -10
- package/server/server.js +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* slip. A notification's link (#/plugins/shopping-cart/orders/<id>) opens one.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {api, copy, entriesOf, esc, fmtDate, fmtDateTime, money, moneyIn, moneyOut, mountPanels, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
|
|
12
|
+
import {api, claims, copy, deferItems, entriesOf, esc, fmtDate, fmtDateTime, money, moneyIn, moneyOut, mountPanels, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
|
|
13
13
|
import {NEXT, STATUS_LABEL, STATUS_TONE, addressLines, orderLabel} from '/plugins/shopping-cart/public/lib/orders.js';
|
|
14
14
|
|
|
15
15
|
const FILTERS = [
|
|
@@ -83,9 +83,9 @@ export async function mount(root, ctx) {
|
|
|
83
83
|
<input type="search" class="form-input shp-search" placeholder="Search # , name, email, item…" aria-label="Search orders" data-model="query.value">
|
|
84
84
|
<span data-help-title="Orders" data-help="Click an order to open it. Right-click (or ⋮, or Shift+F10) for its moves: mark it paid, fulfilled, refunded or cancelled. 'Started' are checkouts that never finished paying - they hold no stock and can be deleted."></span>
|
|
85
85
|
</div>
|
|
86
|
-
<
|
|
86
|
+
<div class="shp-loading-slot" data-bind-hidden="!loading.value">Loading…</div>
|
|
87
87
|
<p class="shp-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
|
|
88
|
-
<p class="shp-note" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
|
|
88
|
+
<p class="shp-note shp-empty" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
|
|
89
89
|
<div class="shp-list" data-each="rows.value key=id"></div>`;
|
|
90
90
|
root.appendChild(view);
|
|
91
91
|
const list = view.querySelector('.shp-list');
|
|
@@ -98,16 +98,24 @@ export async function mount(root, ctx) {
|
|
|
98
98
|
if (b) filter.value = b.dataset.filter;
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
/** The first load, under a skeleton. */
|
|
101
102
|
async function load() {
|
|
102
103
|
try {
|
|
103
|
-
|
|
104
|
+
const res = await kit.whileLoading(view.querySelector('.shp-loading-slot'), api.orders.list(), kit.SHAPES.rows);
|
|
105
|
+
if (!here()) return;
|
|
106
|
+
orders.value = entriesOf(res);
|
|
104
107
|
error.value = '';
|
|
105
108
|
} catch (err) {
|
|
109
|
+
if (!here()) return;
|
|
106
110
|
error.value = `Could not load the orders: ${err.message || err}`;
|
|
107
111
|
} finally {
|
|
108
|
-
loading.value = false;
|
|
112
|
+
if (here()) loading.value = false;
|
|
109
113
|
}
|
|
110
114
|
}
|
|
115
|
+
// One panel per order and per action at a time (a double click, a row clicked while it is still opening).
|
|
116
|
+
const opening = claims();
|
|
117
|
+
// Fulfil and Refund panels: closed with the screen, so nothing can be sent from a view that has gone.
|
|
118
|
+
const actionPanels = new Set();
|
|
111
119
|
const byId = (id) => orders.peek().find(o => o.id === id);
|
|
112
120
|
const replace = (o) => { orders.value = orders.peek().map(x => (x.id === o.id ? o : x)); };
|
|
113
121
|
|
|
@@ -127,20 +135,29 @@ export async function mount(root, ctx) {
|
|
|
127
135
|
}
|
|
128
136
|
|
|
129
137
|
async function markPaid(o) {
|
|
130
|
-
if (!await E.confirm(`Mark ${orderLabel(o.data)} as paid (${esc(money(o.data.total))})? Stock is taken now
|
|
138
|
+
if (!await E.confirm(`Mark ${orderLabel(o.data)} as paid (${esc(money(o.data.total))})? Stock is taken now.`,
|
|
139
|
+
{title: 'Mark paid', confirmText: 'Mark paid', cancelText: 'Not yet'})) return null;
|
|
140
|
+
if (!here()) return null;
|
|
131
141
|
return move(o, 'paid', {note: 'Payment received'});
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
async function cancel(o) {
|
|
135
|
-
if (!await E.confirm(`Cancel ${orderLabel(o.data)}? Nothing was paid, so nothing is refunded
|
|
145
|
+
if (!await E.confirm(`Cancel ${orderLabel(o.data)}? Nothing was paid, so nothing is refunded.`,
|
|
146
|
+
{title: 'Cancel order', confirmText: 'Cancel the order', cancelText: 'Keep it'})) return null;
|
|
147
|
+
if (!here()) return null;
|
|
136
148
|
return move(o, 'cancelled');
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
/** Fulfil, with tracking if there is any - a small form of its own. */
|
|
140
152
|
function fulfil(o) {
|
|
153
|
+
const release = opening.take(`fulfil:${o.id}`);
|
|
154
|
+
if (!release) return Promise.resolve(null);
|
|
141
155
|
return new Promise((resolve) => {
|
|
142
156
|
const t = o.data.tracking || {};
|
|
143
157
|
const f = {carrier: M.observable(t.carrier || ''), number: M.observable(t.number || ''), url: M.observable(t.url || '')};
|
|
158
|
+
const typed = () => JSON.stringify([f.carrier.peek(), f.number.peek(), f.url.peek()]);
|
|
159
|
+
const opened = typed();
|
|
160
|
+
let saving = false;
|
|
144
161
|
let panel = null;
|
|
145
162
|
const collect = o.data.delivery === 'collect';
|
|
146
163
|
openPanel({
|
|
@@ -157,15 +174,27 @@ export async function mount(root, ctx) {
|
|
|
157
174
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
158
175
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="save"><span data-icon="check" data-icon-size="13"></span> ${collect ? 'Collected' : 'Fulfilled'}</button></div>
|
|
159
176
|
</div>`,
|
|
160
|
-
|
|
177
|
+
dirty: () => typed() !== opened,
|
|
178
|
+
busy: () => saving,
|
|
179
|
+
cleanup: () => { actionPanels.delete(panel); release(); resolve(null); },
|
|
161
180
|
methods: {
|
|
162
|
-
cancel: () => panel?.close(
|
|
181
|
+
cancel: () => panel?.close(),
|
|
163
182
|
async save() {
|
|
183
|
+
if (saving || !panel?.isOpen() || !here()) return;
|
|
184
|
+
// The panel closes once it is done, so the form is locked while the move runs.
|
|
185
|
+
saving = true;
|
|
186
|
+
panel.lock(true);
|
|
164
187
|
const next = await move(o, 'fulfilled', {tracking: {carrier: f.carrier.peek(), number: f.number.peek(), url: f.url.peek()}});
|
|
165
|
-
|
|
188
|
+
saving = false;
|
|
189
|
+
if (next) { resolve(next); panel.close(true); } else if (panel.isOpen()) panel.lock(false);
|
|
166
190
|
}
|
|
167
191
|
}
|
|
168
|
-
}).then(p => {
|
|
192
|
+
}).then(p => {
|
|
193
|
+
panel = p;
|
|
194
|
+
actionPanels.add(p);
|
|
195
|
+
if (!here()) p.close(true);
|
|
196
|
+
else p.panel.querySelector('input')?.focus();
|
|
197
|
+
}, (err) => { release(); resolve(null); E.toast(err.message || String(err), {type: 'error'}); });
|
|
169
198
|
});
|
|
170
199
|
}
|
|
171
200
|
|
|
@@ -177,6 +206,8 @@ export async function mount(root, ctx) {
|
|
|
177
206
|
* sanitised, which strips inputs, so the amount, reason and restock box never showed and OK refunded it all.)
|
|
178
207
|
*/
|
|
179
208
|
function refund(o) {
|
|
209
|
+
const release = opening.take(`refund:${o.id}`);
|
|
210
|
+
if (!release) return Promise.resolve(null);
|
|
180
211
|
return new Promise((resolve) => {
|
|
181
212
|
const d = o.data;
|
|
182
213
|
const via = {stripe: 'through Stripe', paypal: 'through PayPal'}[d.provider];
|
|
@@ -185,6 +216,7 @@ export async function mount(root, ctx) {
|
|
|
185
216
|
const f = {amount: M.observable(moneyIn(left)), note: M.observable(''), saving: M.observable(false)};
|
|
186
217
|
let restock = Boolean(d.stockTaken);
|
|
187
218
|
const full = () => moneyOut(f.amount.peek()) === left;
|
|
219
|
+
const opened = JSON.stringify([f.amount.peek(), f.note.peek()]);
|
|
188
220
|
let panel = null;
|
|
189
221
|
let box = null;
|
|
190
222
|
// Stock only comes back when the whole order is refunded, so the box greys out on a part refund.
|
|
@@ -212,11 +244,13 @@ export async function mount(root, ctx) {
|
|
|
212
244
|
box.addEventListener('change', () => { restock = box.checked; });
|
|
213
245
|
syncBox();
|
|
214
246
|
},
|
|
215
|
-
|
|
247
|
+
dirty: () => JSON.stringify([f.amount.peek(), f.note.peek()]) !== opened,
|
|
248
|
+
busy: () => f.saving.peek(),
|
|
249
|
+
cleanup: () => { actionPanels.delete(panel); stop?.(); release(); resolve(null); },
|
|
216
250
|
methods: {
|
|
217
|
-
cancel: () => panel?.close(
|
|
251
|
+
cancel: () => panel?.close(),
|
|
218
252
|
async save() {
|
|
219
|
-
if (f.saving.peek()) return;
|
|
253
|
+
if (f.saving.peek() || !panel?.isOpen() || !here()) return;
|
|
220
254
|
const amount = moneyOut(f.amount.peek());
|
|
221
255
|
if (!Number.isInteger(amount) || amount <= 0 || amount > left) {
|
|
222
256
|
E.toast(`Enter an amount between ${money(1)} and ${money(left)}.`, {type: 'error'});
|
|
@@ -224,29 +258,40 @@ export async function mount(root, ctx) {
|
|
|
224
258
|
}
|
|
225
259
|
const all = amount === left;
|
|
226
260
|
f.saving.value = true;
|
|
261
|
+
panel.lock(true);
|
|
227
262
|
try {
|
|
228
263
|
const r = await api.orders.refund(o.id, {amount: all ? undefined : amount, restock: all && restock, note: f.note.peek()});
|
|
229
264
|
replace(r.order);
|
|
230
265
|
refreshBadge();
|
|
231
266
|
E.toast(`${orderLabel(d)} ${r.how}.`, {type: 'success'});
|
|
232
267
|
resolve(r.order);
|
|
233
|
-
|
|
268
|
+
f.saving.value = false;
|
|
269
|
+
panel.close(true);
|
|
234
270
|
} catch (err) {
|
|
235
271
|
E.toast(err.message || String(err), {type: 'error'});
|
|
236
272
|
} finally {
|
|
237
273
|
f.saving.value = false;
|
|
274
|
+
if (panel.isOpen()) panel.lock(false);
|
|
238
275
|
}
|
|
239
276
|
}
|
|
240
277
|
}
|
|
241
|
-
}).then(p => {
|
|
278
|
+
}).then(p => {
|
|
279
|
+
panel = p;
|
|
280
|
+
actionPanels.add(p);
|
|
281
|
+
if (!here()) p.close(true);
|
|
282
|
+
else p.panel.querySelector('#shp-refund-amount')?.focus();
|
|
283
|
+
}, (err) => { release(); resolve(null); E.toast(err.message || String(err), {type: 'error'}); });
|
|
242
284
|
});
|
|
243
285
|
}
|
|
244
286
|
|
|
245
287
|
async function remove(o) {
|
|
246
|
-
if (!await E.confirm(`Delete ${orderLabel(o.data)}? It never took money; it cannot be brought back
|
|
288
|
+
if (!await E.confirm(`Delete ${orderLabel(o.data)}? It never took money; it cannot be brought back.`,
|
|
289
|
+
{title: 'Delete order', confirmText: 'Delete', cancelText: 'Keep it'})) return false;
|
|
290
|
+
if (!here()) return false;
|
|
247
291
|
try {
|
|
248
292
|
await api.orders.remove(o.id);
|
|
249
293
|
orders.value = orders.peek().filter(x => x.id !== o.id);
|
|
294
|
+
refreshBadge();
|
|
250
295
|
E.toast(`${orderLabel(o.data)} deleted.`, {type: 'success'});
|
|
251
296
|
return true;
|
|
252
297
|
} catch (err) {
|
|
@@ -309,16 +354,49 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
|
|
|
309
354
|
|
|
310
355
|
// ---- One order ----------------------------------------------------------------
|
|
311
356
|
|
|
312
|
-
const panels = new
|
|
357
|
+
const panels = new Map();
|
|
358
|
+
/** Open an order - once: a second click on one already opening or open just goes to it. */
|
|
313
359
|
async function openOrder(id) {
|
|
360
|
+
const open = panels.get(id);
|
|
361
|
+
if (open?.isOpen()) { open.panel.querySelector('button, textarea')?.focus(); return; }
|
|
362
|
+
const release = opening.take(`order:${id}`);
|
|
363
|
+
if (!release) return;
|
|
364
|
+
let owned = false;
|
|
365
|
+
try {
|
|
366
|
+
owned = await openOrderNow(id, release);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
E.toast(`The order could not open: ${err.message || err}`, {type: 'error'});
|
|
369
|
+
} finally {
|
|
370
|
+
if (!owned) release();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function openOrderNow(id, release) {
|
|
314
375
|
let o = byId(id);
|
|
315
376
|
if (!o) {
|
|
316
|
-
try { o = await api.orders.get(id); } catch { E.toast('That order is gone.', {type: 'warning'}); return; }
|
|
377
|
+
try { o = await api.orders.get(id); } catch { if (here()) E.toast('That order is gone.', {type: 'warning'}); return false; }
|
|
378
|
+
if (!here()) return false;
|
|
317
379
|
}
|
|
318
380
|
const d = o.data;
|
|
319
381
|
const staffNote = M.observable(d.staffNote || '');
|
|
320
382
|
const status = M.observable(d.status);
|
|
321
383
|
let panel = null;
|
|
384
|
+
let menu = null;
|
|
385
|
+
// The note as last saved (or being saved): a blur and a close straight after it send it once.
|
|
386
|
+
let noteSent = d.staffNote || '';
|
|
387
|
+
const saveNote = async () => {
|
|
388
|
+
const text = staffNote.peek();
|
|
389
|
+
if (text === noteSent) return;
|
|
390
|
+
noteSent = text;
|
|
391
|
+
try {
|
|
392
|
+
const next = await api.orders.note(o.id, {staffNote: text});
|
|
393
|
+
replace(next);
|
|
394
|
+
E.toast('Note saved.', {type: 'success'});
|
|
395
|
+
} catch (err) {
|
|
396
|
+
noteSent = null;
|
|
397
|
+
E.toast(`Note not saved: ${err.message || err}`, {type: 'error'});
|
|
398
|
+
}
|
|
399
|
+
};
|
|
322
400
|
let extPanels = null;
|
|
323
401
|
const ext = Object.entries(d.ext || {}).flatMap(([owner, facts]) => Object.entries(facts || {}).map(([k, v]) => ({owner, k, v})))
|
|
324
402
|
.filter(x => ['string', 'number', 'boolean'].includes(typeof x.v));
|
|
@@ -366,9 +444,17 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
|
|
|
366
444
|
model: {staffNote, status},
|
|
367
445
|
html,
|
|
368
446
|
before: (el) => { const n = el.querySelector('.shp-callout p'); if (n) n.textContent = d.note; },
|
|
369
|
-
cleanup: () => {
|
|
447
|
+
cleanup: () => {
|
|
448
|
+
if (panels.get(o.id) === panel) panels.delete(o.id);
|
|
449
|
+
release();
|
|
450
|
+
extPanels?.destroy();
|
|
451
|
+
menu?.destroy?.();
|
|
452
|
+
// "Saved when you click away" - closing (or leaving the screen) with the note changed is clicking away.
|
|
453
|
+
saveNote();
|
|
454
|
+
}
|
|
370
455
|
});
|
|
371
|
-
panels.
|
|
456
|
+
panels.set(o.id, panel);
|
|
457
|
+
if (!here()) { panel.close(true); return true; }
|
|
372
458
|
const el = panel.panel;
|
|
373
459
|
// Extensions' sections (Shopping Cart Pro's Downloads). `changed` takes the order an extension
|
|
374
460
|
// updated, so the list keeps up.
|
|
@@ -376,7 +462,7 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
|
|
|
376
462
|
order: o,
|
|
377
463
|
changed: (next) => { if (next?.id === o.id) replace(next); }
|
|
378
464
|
}});
|
|
379
|
-
if (!
|
|
465
|
+
if (!panel.isOpen() || !here()) { extPanels.destroy(); return true; }
|
|
380
466
|
|
|
381
467
|
// Main actions as buttons, the rest in ⋮.
|
|
382
468
|
const renderActs = (cur) => {
|
|
@@ -389,8 +475,11 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
|
|
|
389
475
|
b.innerHTML = `<span data-icon="${a.icon}" data-icon-size="13"></span> `;
|
|
390
476
|
b.appendChild(document.createTextNode(a.label.replace('…', '')));
|
|
391
477
|
b.addEventListener('click', async () => {
|
|
478
|
+
// After this event: an Enter on the button must not answer the question it asks (see kit soon()).
|
|
479
|
+
await new Promise(r => setTimeout(r, 0));
|
|
480
|
+
if (!panel.isOpen()) return;
|
|
392
481
|
const next = await a.run();
|
|
393
|
-
if (next && typeof next === 'object') { panel.close(true); openOrder(next.id); }
|
|
482
|
+
if (next && typeof next === 'object' && here()) { panel.close(true); openOrder(next.id); }
|
|
394
483
|
});
|
|
395
484
|
box.appendChild(b);
|
|
396
485
|
}
|
|
@@ -398,24 +487,16 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
|
|
|
398
487
|
};
|
|
399
488
|
renderActs(o);
|
|
400
489
|
const more = el.querySelector('[data-part="more"]');
|
|
401
|
-
|
|
490
|
+
menu = E.contextMenu(more, {items: () => deferItems(items(o.id).filter(x => x.label !== 'Open')), inherit: false, density: 'compact', keyboardTrigger: false});
|
|
402
491
|
more.addEventListener('click', () => kit.openMenuAt(more));
|
|
403
|
-
|
|
492
|
+
more.addEventListener('keydown', (e) => { if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); e.stopPropagation(); kit.openMenuAt(more); } });
|
|
404
493
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (staffNote.peek() === (byId(o.id)?.data.staffNote || '')) return;
|
|
408
|
-
try {
|
|
409
|
-
replace(await api.orders.note(o.id, {staffNote: staffNote.peek()}));
|
|
410
|
-
E.toast('Note saved.', {type: 'success'});
|
|
411
|
-
} catch (err) {
|
|
412
|
-
E.toast(`Note not saved: ${err.message || err}`, {type: 'error'});
|
|
413
|
-
}
|
|
414
|
-
});
|
|
494
|
+
el.querySelector('[data-part="staff"]').addEventListener('blur', () => saveNote());
|
|
495
|
+
return true;
|
|
415
496
|
}
|
|
416
497
|
|
|
417
498
|
wireRows(list, {openMenuAt: kit.openMenuAt, scope, items, main: openOrder});
|
|
418
|
-
scope(() => [...panels].forEach(
|
|
499
|
+
scope(() => [...panels.values(), ...actionPanels].forEach(pn => { try { pn.close(true); } catch { /* gone */ } }));
|
|
419
500
|
|
|
420
501
|
await load();
|
|
421
502
|
if (!here()) return;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {barChart} from '../lib/chart.js';
|
|
11
|
-
import {api, esc, fmtDate, money, when} from '../lib/kit.js';
|
|
11
|
+
import {api, copy, esc, fmtDate, money, when, wireRows} from '../lib/kit.js';
|
|
12
12
|
import {STATUS_LABEL, STATUS_TONE} from '/plugins/shopping-cart/public/lib/orders.js';
|
|
13
13
|
|
|
14
14
|
export async function mount(root, ctx) {
|
|
@@ -21,7 +21,7 @@ export async function mount(root, ctx) {
|
|
|
21
21
|
view.className = 'shp-overview';
|
|
22
22
|
view.innerHTML = `
|
|
23
23
|
<p class="shp-error" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
|
|
24
|
-
<div class="shp-loading" data-bind-hidden="s.value || error.value">Loading…</div>
|
|
24
|
+
<div class="shp-loading-slot" data-bind-hidden="s.value || error.value">Loading…</div>
|
|
25
25
|
<div data-part="start" hidden></div>
|
|
26
26
|
<div data-part="main" hidden>
|
|
27
27
|
<div class="shp-tiles" data-part="tiles"></div>
|
|
@@ -54,12 +54,13 @@ export async function mount(root, ctx) {
|
|
|
54
54
|
scope(() => bindings.dispose());
|
|
55
55
|
|
|
56
56
|
try {
|
|
57
|
-
|
|
57
|
+
const res = await kit.whileLoading(view.querySelector('.shp-loading-slot'), api.summary(), kit.SHAPES.tiles);
|
|
58
|
+
if (!here()) return;
|
|
59
|
+
s.value = res;
|
|
58
60
|
} catch (err) {
|
|
59
|
-
error.value = `Could not load the figures: ${err.message || err}`;
|
|
61
|
+
if (here()) error.value = `Could not load the figures: ${err.message || err}`;
|
|
60
62
|
return;
|
|
61
63
|
}
|
|
62
|
-
if (!here()) return;
|
|
63
64
|
const d = s.peek();
|
|
64
65
|
|
|
65
66
|
// Nothing sold and nothing to sell: show the way in.
|
|
@@ -141,20 +142,49 @@ export async function mount(root, ctx) {
|
|
|
141
142
|
|
|
142
143
|
// ---- Latest orders, best sellers ----
|
|
143
144
|
part('recent').innerHTML = d.recent.length ? d.recent.map(o => `
|
|
144
|
-
<a class="shp-mini" href="#/plugins/shopping-cart/orders/${esc(o.id)}">
|
|
145
|
+
<a class="shp-mini" href="#/plugins/shopping-cart/orders/${esc(o.id)}" data-id="${esc(o.id)}">
|
|
145
146
|
<span class="shp-mini-main"><span><strong>#${esc(o.number)}</strong> ${esc(o.name || '')}</span><small>${esc(when(o.at))} · ${o.items} item${o.items === 1 ? '' : 's'}</small></span>
|
|
146
147
|
<span class="shp-chip is-${STATUS_TONE[o.status]}">${esc(STATUS_LABEL[o.status])}</span>
|
|
147
148
|
<span class="shp-mini-amt">${esc(money(o.total))}</span>
|
|
149
|
+
<button type="button" class="shp-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
148
150
|
</a>`).join('') : '<p class="shp-muted">No orders yet.</p>';
|
|
149
151
|
|
|
150
152
|
const maxRev = Math.max(1, ...d.topProducts.map(p => p.revenue));
|
|
151
|
-
part('top').innerHTML = d.topProducts.length ? d.topProducts.map(p => `
|
|
152
|
-
<div class="shp-top">
|
|
153
|
+
part('top').innerHTML = d.topProducts.length ? d.topProducts.map((p, i) => `
|
|
154
|
+
<div class="shp-top${p.productId ? ' is-link' : ''}" data-id="${esc(p.productId || '')}" data-i="${i}"${p.productId ? ' tabindex="0"' : ''}>
|
|
153
155
|
<span class="shp-top-name">${esc(p.name)}<small>${p.qty} sold</small></span>
|
|
154
156
|
<span class="shp-top-bar"><span style="width:${Math.round(p.revenue / maxRev * 100)}%"></span></span>
|
|
155
157
|
<span class="shp-mini-amt">${esc(money(p.revenue))}</span>
|
|
156
158
|
</div>`).join('') : '<p class="shp-muted">Nothing sold in the last 30 days.</p>';
|
|
157
159
|
|
|
160
|
+
// Menus on both lists: right-click, ⋮ or Shift+F10; click (or Enter) opens.
|
|
161
|
+
const recent = part('recent');
|
|
162
|
+
const byOrder = (id) => d.recent.find(o => o.id === id);
|
|
163
|
+
// The rows are links (open in a new tab works); the ⋮ inside one must not follow it.
|
|
164
|
+
recent.addEventListener('click', (e) => { if (e.target.closest('.shp-kebab')) e.preventDefault(); });
|
|
165
|
+
// main: nothing - the row is a link and follows itself (click or Enter).
|
|
166
|
+
wireRows(recent, {match: '.shp-mini', openMenuAt: kit.openMenuAt, scope, main: () => {}, items: (id) => {
|
|
167
|
+
const o = byOrder(id);
|
|
168
|
+
if (!o) return [];
|
|
169
|
+
return [
|
|
170
|
+
{header: `#${o.number}${o.name ? ` · ${o.name}` : ''}`},
|
|
171
|
+
{label: 'Open', icon: 'eye', action: () => go('orders', id)},
|
|
172
|
+
{label: 'Copy the order number', icon: 'copy', action: () => copy(`#${o.number}`, 'Order number copied')},
|
|
173
|
+
{type: 'divider'},
|
|
174
|
+
{label: 'All orders', icon: 'receipt', action: () => go('orders')}
|
|
175
|
+
];
|
|
176
|
+
}});
|
|
177
|
+
const top = part('top');
|
|
178
|
+
wireRows(top, {match: '.shp-top.is-link', openMenuAt: kit.openMenuAt, scope, main: (id) => go('products', id), items: (id) => {
|
|
179
|
+
const p = d.topProducts.find(x => x.productId === id);
|
|
180
|
+
if (!p) return [];
|
|
181
|
+
return [
|
|
182
|
+
{header: p.name},
|
|
183
|
+
{label: 'Edit the product', icon: 'edit', action: () => go('products', id)},
|
|
184
|
+
{label: 'All products', icon: 'package', action: () => go('products')}
|
|
185
|
+
];
|
|
186
|
+
}});
|
|
187
|
+
|
|
158
188
|
I.scan(view);
|
|
159
189
|
}
|
|
160
190
|
|