domma-cms 1.6.36 → 1.6.38
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/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/compose-window.js +4 -0
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +15 -6
- package/plugins/_lib/mail/compose.js +7 -2
- package/plugins/_lib/mail/message-action.js +56 -0
- package/plugins/_lib/mail/scheduler.js +2 -0
- package/plugins/_lib/mail/send.js +12 -3
- package/plugins/_lib/mail/store.js +16 -6
- package/plugins/_lib/outreach/index.js +1 -1
- package/plugins/_lib/outreach/ledger.js +10 -3
- package/plugins/_lib/outreach/sending.js +9 -3
- package/plugins/_lib/outreach/suppression.js +14 -3
- package/plugins/_lib/outreach/unsubscribe.js +13 -0
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/lib/cache.js +43 -0
- package/plugins/blog/lib/page.js +28 -9
- package/plugins/blog/plugin.js +14 -13
- package/plugins/blog/plugin.json +1 -1
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/cache.test.js +65 -0
- package/plugins/booking/CLAUDE.md +14 -2
- package/plugins/booking/admin/lib/diary-layout.js +18 -0
- package/plugins/booking/admin/views/diary.js +153 -152
- package/plugins/booking/admin/views/people.js +64 -66
- package/plugins/booking/admin/views/settings.js +31 -32
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/admin-diary.test.js +9 -1
- package/plugins/feedback/CLAUDE.md +55 -0
- package/plugins/feedback/admin/css/index.css +16 -1
- package/plugins/feedback/admin/lib/kit.js +106 -0
- package/plugins/feedback/admin/lib/settings.js +107 -0
- package/plugins/feedback/admin/views/feedback.js +4 -1
- package/plugins/feedback/admin/views/inbox.js +11 -2
- package/plugins/feedback/admin/views/send.js +74 -7
- package/plugins/feedback/docs/guide.md +9 -9
- package/plugins/feedback/lib/attachments.js +133 -0
- package/plugins/feedback/lib/cache.js +43 -0
- package/plugins/feedback/lib/receiver.js +36 -6
- package/plugins/feedback/lib/sender.js +70 -6
- package/plugins/feedback/lib/shape.js +111 -6
- package/plugins/feedback/plugin.js +171 -17
- package/plugins/feedback/plugin.json +2 -2
- package/plugins/feedback/tests/api.test.js +125 -3
- package/plugins/feedback/tests/settings.test.js +59 -0
- package/plugins/feedback/tests/shape.test.js +64 -0
- package/plugins/free-tier.lock.json +73 -61
- package/plugins/mail-reader/CLAUDE.md +15 -4
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/shopping-cart/CLAUDE.md +28 -2
- package/plugins/shopping-cart/admin/css/index.css +3 -0
- package/plugins/shopping-cart/admin/views/orders.js +42 -10
- package/plugins/shopping-cart/admin/views/shop.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +7 -3
- package/plugins/shopping-cart/lib/payments.js +15 -2
- package/plugins/shopping-cart/lib/refunds.js +51 -0
- package/plugins/shopping-cart/lib/sales.js +15 -8
- package/plugins/shopping-cart/lib/shop.js +38 -3
- package/plugins/shopping-cart/plugin.js +47 -19
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/api.test.js +52 -0
- package/plugins/shopping-cart/tests/commission.test.js +1 -1
- package/plugins/shopping-cart/tests/refunds.test.js +42 -0
- package/plugins/support/CLAUDE.md +51 -0
- package/plugins/support/admin/css/index.css +16 -1
- package/plugins/support/admin/lib/kit.js +192 -8
- package/plugins/support/admin/lib/settings.js +123 -0
- package/plugins/support/admin/views/desk.js +55 -12
- package/plugins/support/admin/views/inbox.js +17 -4
- package/plugins/support/admin/views/support.js +4 -1
- package/plugins/support/docs/guide.md +7 -9
- package/plugins/support/lib/attachments.js +133 -0
- package/plugins/support/lib/cache.js +43 -0
- package/plugins/support/lib/receiver.js +45 -10
- package/plugins/support/lib/sender.js +81 -10
- package/plugins/support/lib/shape.js +153 -12
- package/plugins/support/plugin.js +159 -29
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +125 -3
- package/plugins/support/tests/settings.test.js +68 -0
- package/plugins/support/tests/shape.test.js +51 -1
- package/server/routes/outreach-public.js +54 -10
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
* is shaded. A booking is a block: click opens it, right-click / ⋮ /
|
|
7
7
|
* Shift+F10 is its menu. Click an empty time to book it. The first time
|
|
8
8
|
* round, a checklist says what to set up.
|
|
9
|
+
*
|
|
10
|
+
* domma-reactive throughout: the heads, the hour gutter and the columns (each
|
|
11
|
+
* with its open bands and blocks) are keyed lists fed from one `draw` effect
|
|
12
|
+
* that works the geometry out in JS (diary-layout.js); the markup only binds.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
15
|
import {STATUS_LABEL, V, api, copy, errText, esc, openPanel, store, wireRows} from '../lib/kit.js';
|
|
12
|
-
import {columnItems, dayLabel, datesFor, gridHours, layoutColumn, minuteAt, placeOf} from '../lib/diary-layout.js';
|
|
16
|
+
import {columnItems, dayLabel, datesFor, gridHours, layoutColumn, mergeWindows, minuteAt, placeOf} from '../lib/diary-layout.js';
|
|
13
17
|
import {withNames} from '../lib/details.js';
|
|
14
18
|
import {addDays, atWallClock, fromMinutes, isDate, wallClock} from '/plugins/booking/public/lib/time.js';
|
|
15
19
|
import {windowsOn} from '/plugins/booking/public/lib/slots.js';
|
|
@@ -18,6 +22,37 @@ const PX_PER_MIN = 1; // an hour is 60px
|
|
|
18
22
|
const VIEW_KEY = 'booking.diary-view';
|
|
19
23
|
const CANCELLED_KEY = 'booking.diary-cancelled';
|
|
20
24
|
const CHECKLIST_KEY = 'booking.checklist-hidden';
|
|
25
|
+
const STATUS_NOTE = {completed: 'done', 'no-show': 'no-show', cancelled: 'cancelled', pending: 'pending'};
|
|
26
|
+
|
|
27
|
+
// A first-run step. The two icon wrappers swap with `done` (I.scan replaces the icon span itself).
|
|
28
|
+
const STEP = `
|
|
29
|
+
<li data-bind-class="done && 'is-done'">
|
|
30
|
+
<span class="bk-step-icon" data-bind-hidden="!done"><span data-icon="check-circle" data-icon-size="14"></span></span>
|
|
31
|
+
<span class="bk-step-icon" data-bind-hidden="done"><span data-icon="circle" data-icon-size="14"></span></span>
|
|
32
|
+
<button type="button" class="bk-link" data-on-click="$parent.step($data)" data-bind-text="text"></button>
|
|
33
|
+
</li>`;
|
|
34
|
+
|
|
35
|
+
// A column heading: a person or room (day view) or a day (week view, a link to it).
|
|
36
|
+
const HEAD = `
|
|
37
|
+
<div class="bk-col-head" data-bind-class="cls" data-bind-data-colour="colour" data-bind-data-date="date" data-bind-title="tip">
|
|
38
|
+
<span class="bk-kind" data-bind-hidden="kind !== 'person'"><span data-icon="user" data-icon-size="13"></span></span>
|
|
39
|
+
<span class="bk-kind" data-bind-hidden="kind !== 'resource'"><span data-icon="box" data-icon-size="13"></span></span>
|
|
40
|
+
<span data-bind-text="title"></span>
|
|
41
|
+
</div>`;
|
|
42
|
+
|
|
43
|
+
const HOUR = `<span data-bind-style-top="top" data-bind-text="text"></span>`;
|
|
44
|
+
|
|
45
|
+
// A column: its open bands, the now line and its blocks. Every measure is a CSS length worked out in draw().
|
|
46
|
+
const COL = `
|
|
47
|
+
<div class="bk-col" data-bind-data-date="date" data-bind-data-bookable="bookableId" title="Click an empty time to book it">
|
|
48
|
+
{{#each windows key=id}}<div class="bk-open" data-bind-style-top="top" data-bind-style-background-position-y="bgY" data-bind-style-height="height"></div>{{/each}}
|
|
49
|
+
<div class="bk-now" data-bind-hidden="!nowTop" data-bind-style-top="nowTop"></div>
|
|
50
|
+
{{#each blocks key=id}}<div class="bk-block" data-bind-data-id="id" data-bind-class="cls" data-bind-data-colour="colour" data-bind-title="tip" tabindex="0" role="button"
|
|
51
|
+
data-bind-style-top="top" data-bind-style-height="height" data-bind-style-left="left" data-bind-style-width="width">
|
|
52
|
+
<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
53
|
+
<strong data-bind-text="head"></strong><small data-bind-text="sub"></small>
|
|
54
|
+
</div>{{/each}}
|
|
55
|
+
</div>`;
|
|
21
56
|
|
|
22
57
|
export async function mount(root, ctx) {
|
|
23
58
|
const {kit, scope, here, boot} = ctx;
|
|
@@ -33,6 +68,16 @@ export async function mount(root, ctx) {
|
|
|
33
68
|
const loading = M.observable(true);
|
|
34
69
|
const error = M.observable('');
|
|
35
70
|
const label = M.observable('');
|
|
71
|
+
// What the grid shows, as rows for the keyed lists.
|
|
72
|
+
const hasBookables = M.observable(boot.bookables.length > 0);
|
|
73
|
+
const hasServices = M.observable(boot.services.length > 0);
|
|
74
|
+
const heads = M.observable([]);
|
|
75
|
+
const hours = M.observable([]);
|
|
76
|
+
const cols = M.observable([]);
|
|
77
|
+
const gridCols = M.observable('1');
|
|
78
|
+
const gridH = M.observable('0px');
|
|
79
|
+
const steps = M.observable([]);
|
|
80
|
+
const checklistOn = M.observable(false);
|
|
36
81
|
scope(M.effect(() => store.set(VIEW_KEY, view.value)));
|
|
37
82
|
scope(M.effect(() => store.set(CANCELLED_KEY, showCancelled.value)));
|
|
38
83
|
|
|
@@ -40,7 +85,11 @@ export async function mount(root, ctx) {
|
|
|
40
85
|
const card = document.createElement('div');
|
|
41
86
|
card.className = 'bk-card bk-diary';
|
|
42
87
|
card.innerHTML = `
|
|
43
|
-
<div class="bk-checklist" data-
|
|
88
|
+
<div class="bk-checklist" data-bind-hidden="!checklistOn.value">
|
|
89
|
+
<header><h3><span data-icon="clipboard-list" data-icon-size="15"></span> Getting started</h3>
|
|
90
|
+
<button type="button" class="btn btn-xs btn-ghost" data-on-click="hideChecklist" title="Hide this list">Hide</button></header>
|
|
91
|
+
<ol data-each="steps.value key=id">${STEP}</ol>
|
|
92
|
+
</div>
|
|
44
93
|
<div class="bk-bar">
|
|
45
94
|
<div class="bk-seg" role="group" aria-label="View">
|
|
46
95
|
<button type="button" data-view="day" data-bind-class="view.value === 'day' && 'is-on'">Day</button>
|
|
@@ -58,16 +107,37 @@ export async function mount(root, ctx) {
|
|
|
58
107
|
<span data-help-title="The Diary" data-help="Bookings in your business's time zone (${esc(ctx.tz)}). Hatched times are outside opening hours. Click a booking to open it; right-click it (or ⋮, or Shift+F10) for Move, Done, No-show and Cancel. Click an empty time to book it. Keys: ← → earlier and later, T today, D day, W week."></span>
|
|
59
108
|
</div>
|
|
60
109
|
<p class="bk-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
|
|
61
|
-
<div class="bk-diary-wrap" data-part="grid" tabindex="-1"
|
|
110
|
+
<div class="bk-diary-wrap" data-part="grid" tabindex="-1">
|
|
111
|
+
<div class="bk-empty" data-bind-hidden="hasBookables.value"><span data-icon="users" data-icon-size="28"></span>
|
|
112
|
+
<p><strong>Add who or what can be booked.</strong><br>A person (for appointments) or a room or thing - each with their opening hours.</p>
|
|
113
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="addPerson"><span data-icon="plus" data-icon-size="13"></span> Add a person or room</button></div>
|
|
114
|
+
<p class="bk-note" data-bind-hidden="!hasBookables.value || hasServices.value">Next: <button type="button" class="bk-link" data-on-click="addService">add a service</button> - what can be booked, for how long, and with whom.</p>
|
|
115
|
+
<div class="bk-diary-grid" data-bind-hidden="!hasBookables.value" data-bind-class="'is-' + view.value"
|
|
116
|
+
data-bind-style---bk-cols="gridCols.value" data-bind-style---bk-h="gridH.value" style="--bk-hour: ${60 * PX_PER_MIN}px">
|
|
117
|
+
<div class="bk-diary-head" data-part="heads"><span></span><div class="bk-heads" data-each="heads.value key=key">${HEAD}</div></div>
|
|
118
|
+
<div class="bk-diary-body">
|
|
119
|
+
<div class="bk-gutter" data-each="hours.value key=id">${HOUR}</div>
|
|
120
|
+
<div class="bk-cols" data-each="cols.value key=key">${COL}</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>`;
|
|
62
124
|
root.appendChild(card);
|
|
63
125
|
const gridBox = card.querySelector('[data-part="grid"]');
|
|
64
126
|
const dateIn = card.querySelector('[data-part="date"]');
|
|
65
127
|
const cancelledBox = card.querySelector('[data-part="cancelled"]');
|
|
66
|
-
const checklist = card.querySelector('[data-part="checklist"]');
|
|
67
128
|
scope(kit.helpPopovers(card, {position: 'bottom'}));
|
|
68
|
-
const
|
|
129
|
+
const model = {
|
|
130
|
+
view, label, error, hasBookables, hasServices, heads, hours, cols, gridCols, gridH, steps, checklistOn,
|
|
131
|
+
hideChecklist() { store.set(CHECKLIST_KEY, true); checklistOn.value = false; },
|
|
132
|
+
step(s) { s.go(); },
|
|
133
|
+
addPerson: () => ctx.go('people', 'new'),
|
|
134
|
+
addService: () => ctx.go('services', 'new')
|
|
135
|
+
};
|
|
136
|
+
const bindings = M.applyBindings(model, card);
|
|
69
137
|
scope(() => bindings.dispose());
|
|
70
138
|
I.scan(card);
|
|
139
|
+
// Heads come and go with the view; their icons are drawn once the rows are in.
|
|
140
|
+
scope(M.effect(() => { heads.value; steps.value; requestAnimationFrame(() => { if (card.isConnected) I.scan(card); }); }));
|
|
71
141
|
|
|
72
142
|
cancelledBox.checked = showCancelled.peek();
|
|
73
143
|
cancelledBox.addEventListener('change', () => { showCancelled.value = cancelledBox.checked; });
|
|
@@ -84,6 +154,11 @@ export async function mount(root, ctx) {
|
|
|
84
154
|
const s = e.target.closest('[data-step]');
|
|
85
155
|
if (s) step(Number(s.dataset.step));
|
|
86
156
|
});
|
|
157
|
+
// A week head is a link to its day.
|
|
158
|
+
card.querySelector('[data-part="heads"]').addEventListener('click', (e) => {
|
|
159
|
+
const h = e.target.closest('.bk-col-head[data-date]');
|
|
160
|
+
if (h) { date.value = h.dataset.date; view.value = 'day'; }
|
|
161
|
+
});
|
|
87
162
|
const onKey = (e) => {
|
|
88
163
|
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
89
164
|
if (e.target.closest('input, textarea, select, [contenteditable], .dm-slideover, [role="dialog"]')) return;
|
|
@@ -103,28 +178,16 @@ export async function mount(root, ctx) {
|
|
|
103
178
|
// ---- The first-run checklist --------------------------------------------------
|
|
104
179
|
|
|
105
180
|
function drawChecklist() {
|
|
106
|
-
const
|
|
107
|
-
{done: boot.bookables.length > 0, text: 'Add who or what can be booked - a person or a room', go: () => ctx.go('people', 'new')},
|
|
108
|
-
{done: boot.services.length > 0, text: 'Add a service: what can be booked, for how long, and with whom', go: () => ctx.go('services', 'new')},
|
|
109
|
-
{done: store.get('booking.settings-seen') === true, text: boot.mailReady ? 'Check the settings: your booking page, notice rules and emails' : 'Check the settings - and set up outgoing mail, or no confirmation emails are sent',
|
|
181
|
+
const list = [
|
|
182
|
+
{id: 'people', done: boot.bookables.length > 0, text: 'Add who or what can be booked - a person or a room', go: () => ctx.go('people', 'new')},
|
|
183
|
+
{id: 'services', done: boot.services.length > 0, text: 'Add a service: what can be booked, for how long, and with whom', go: () => ctx.go('services', 'new')},
|
|
184
|
+
{id: 'settings', done: store.get('booking.settings-seen') === true, text: boot.mailReady ? 'Check the settings: your booking page, notice rules and emails' : 'Check the settings - and set up outgoing mail, or no confirmation emails are sent',
|
|
110
185
|
go: () => { store.set('booking.settings-seen', true); ctx.openSettings(); drawChecklist(); }},
|
|
111
|
-
{done: store.get('booking.link-shared') === true, text: `Share your booking page: ${location.origin}${ctx.settings.bookingPath}`,
|
|
186
|
+
{id: 'share', done: store.get('booking.link-shared') === true, text: `Share your booking page: ${location.origin}${ctx.settings.bookingPath}`,
|
|
112
187
|
go: () => { store.set('booking.link-shared', true); copy(`${location.origin}${ctx.settings.bookingPath}`, 'Booking page address copied'); drawChecklist(); }}
|
|
113
188
|
];
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
checklist.hidden = false;
|
|
117
|
-
checklist.innerHTML = `
|
|
118
|
-
<header><h3><span data-icon="clipboard-list" data-icon-size="15"></span> Getting started</h3>
|
|
119
|
-
<button type="button" class="btn btn-xs btn-ghost" data-hide title="Hide this list">Hide</button></header>
|
|
120
|
-
<ol>${steps.map((s, i) => `<li class="${s.done ? 'is-done' : ''}"><span data-icon="${s.done ? 'check-circle' : 'circle'}" data-icon-size="14"></span>
|
|
121
|
-
<button type="button" class="bk-link" data-step-i="${i}">${esc(s.text)}</button></li>`).join('')}</ol>`;
|
|
122
|
-
I.scan(checklist);
|
|
123
|
-
checklist.onclick = (e) => {
|
|
124
|
-
if (e.target.closest('[data-hide]')) { store.set(CHECKLIST_KEY, true); checklist.hidden = true; return; }
|
|
125
|
-
const i = e.target.closest('[data-step-i]')?.dataset.stepI;
|
|
126
|
-
if (i !== undefined) steps[Number(i)].go();
|
|
127
|
-
};
|
|
189
|
+
steps.value = list;
|
|
190
|
+
checklistOn.value = list.some(s => !s.done) && store.get(CHECKLIST_KEY) !== true;
|
|
128
191
|
}
|
|
129
192
|
drawChecklist();
|
|
130
193
|
|
|
@@ -168,134 +231,84 @@ export async function mount(root, ctx) {
|
|
|
168
231
|
windows: windowsOn(b, d, ctx.settings.closures)}));
|
|
169
232
|
}
|
|
170
233
|
|
|
234
|
+
const px = (n) => `${n}px`;
|
|
235
|
+
|
|
236
|
+
/** One block's row: where it sits and what it says. */
|
|
237
|
+
function blockRow(p, {from, to, week, colours}) {
|
|
238
|
+
const b = p.b;
|
|
239
|
+
let id = b.id;
|
|
240
|
+
if (p.session) { sessions.set(p.key, p.bookings); id = `session:${p.key}`; }
|
|
241
|
+
const s = wallClock(b.startAt, ctx.tz).time;
|
|
242
|
+
const e = wallClock(b.endAt, ctx.tz).time;
|
|
243
|
+
const time = week ? s : `${s}-${e}`;
|
|
244
|
+
const statusNote = b.status !== 'confirmed' ? ` · ${STATUS_NOTE[b.status] || b.status}` : '';
|
|
245
|
+
let head;
|
|
246
|
+
let sub;
|
|
247
|
+
let tip;
|
|
248
|
+
if (p.session) {
|
|
249
|
+
// A class: one block for everyone in it.
|
|
250
|
+
head = `${time} ${b.serviceName} · ${p.bookings.length} booked`;
|
|
251
|
+
sub = `${p.companion ? `with ${b.bookableName}` : p.bookings.map(x => x.name).join(', ')}${week && b.bookableName ? ` · ${b.bookableName}` : ''}`;
|
|
252
|
+
tip = `${s}-${e} ${b.serviceName}: ${p.bookings.length} booked${b.bookableName ? ` (with ${withNames(b)})` : ''}`;
|
|
253
|
+
} else if (p.companion) {
|
|
254
|
+
// Held along with someone else's booking (a court with the trainer).
|
|
255
|
+
head = `${time} ${b.name}`;
|
|
256
|
+
sub = `${b.serviceName} · with ${b.bookableName}${statusNote}`;
|
|
257
|
+
tip = `${s}-${e} ${b.serviceName}: ${b.name} - held with ${b.bookableName}'s booking`;
|
|
258
|
+
} else {
|
|
259
|
+
head = `${time} ${b.name}`;
|
|
260
|
+
sub = `${b.serviceName}${week && b.bookableName ? ` · ${withNames(b)}` : b.alsoNames?.length ? ` · with ${b.alsoNames.join(', ')}` : ''}${statusNote}`;
|
|
261
|
+
tip = `${s}-${e} ${b.serviceName}: ${b.name}${withNames(b) ? ` (with ${withNames(b)})` : ''}`;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
id, head, sub, tip,
|
|
265
|
+
cls: `is-${b.status}${p.companion ? ' is-companion' : ''}${p.session ? ' is-session' : ''}`,
|
|
266
|
+
colour: colours.get(b.bookableId) || 'grey',
|
|
267
|
+
top: px((Math.max(p.startMin, from) - from) * PX_PER_MIN),
|
|
268
|
+
height: px(Math.max(18, (Math.min(p.endMin, to) - Math.max(p.startMin, from)) * PX_PER_MIN - 2)),
|
|
269
|
+
left: `calc(${(p.lane / p.lanes) * 100}% + 2px)`,
|
|
270
|
+
width: `calc(${100 / p.lanes}% - 4px)`
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
171
274
|
function draw() {
|
|
172
275
|
const list = bookings.value.filter(b => showCancelled.value || b.status !== 'cancelled');
|
|
276
|
+
const week = view.value === 'week';
|
|
173
277
|
const ds = datesFor(view.value, date.value);
|
|
174
|
-
label.value =
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (!boot.bookables.length) {
|
|
178
|
-
gridBox.innerHTML = `<div class="bk-empty"><span data-icon="users" data-icon-size="28"></span>
|
|
179
|
-
<p><strong>Add who or what can be booked.</strong><br>A person (for appointments) or a room or thing - each with their opening hours.</p>
|
|
180
|
-
<button type="button" class="btn btn-sm btn-primary" data-go="people"><span data-icon="plus" data-icon-size="13"></span> Add a person or room</button></div>`;
|
|
181
|
-
gridBox.querySelector('[data-go]').onclick = () => ctx.go('people', 'new');
|
|
182
|
-
I.scan(gridBox);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
const colours = new Map(boot.bookables.map(b => [b.id, b.colour || 'blue']));
|
|
278
|
+
label.value = week ? `${dayLabel(ds[0])} - ${dayLabel(ds[6])} ${ds[6].slice(0, 4)}` : `${dayLabel(ds[0])} ${ds[0].slice(0, 4)}`;
|
|
279
|
+
hasBookables.value = boot.bookables.length > 0;
|
|
280
|
+
hasServices.value = boot.services.length > 0;
|
|
186
281
|
sessions.clear();
|
|
282
|
+
if (!boot.bookables.length) { heads.value = []; hours.value = []; cols.value = []; return; }
|
|
283
|
+
const defs = columns();
|
|
284
|
+
const colours = new Map(boot.bookables.map(b => [b.id, b.colour || 'blue']));
|
|
187
285
|
const placed = list.map(b => ({b, ...placeOf(b, ctx.tz)})).filter(p => ds.includes(p.date));
|
|
188
286
|
const {from, to} = gridHours(boot.bookables.filter(b => b.active !== false), ds, placed);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const grid = document.createElement('div');
|
|
192
|
-
grid.className = `bk-diary-grid is-${view.value}`;
|
|
193
|
-
grid.style.setProperty('--bk-cols', String(cols.length));
|
|
194
|
-
grid.style.setProperty('--bk-h', `${height}px`);
|
|
195
|
-
grid.style.setProperty('--bk-hour', `${60 * PX_PER_MIN}px`);
|
|
196
|
-
|
|
197
|
-
// Heads
|
|
198
|
-
const head = document.createElement('div');
|
|
199
|
-
head.className = 'bk-diary-head';
|
|
200
|
-
head.appendChild(document.createElement('span'));
|
|
201
|
-
for (const c of cols) {
|
|
202
|
-
const h = document.createElement('div');
|
|
203
|
-
h.className = `bk-col-head${c.today && view.value === 'week' ? ' is-today' : ''}`;
|
|
204
|
-
if (c.colour) h.dataset.colour = c.colour;
|
|
205
|
-
h.innerHTML = `${c.kind ? `<span data-icon="${c.kind === 'resource' ? 'box' : 'user'}" data-icon-size="13"></span> ` : ''}<span>${esc(c.title)}</span>`;
|
|
206
|
-
if (view.value === 'week') { h.classList.add('bk-link-head'); h.title = 'Show this day'; h.onclick = () => { date.value = c.date; view.value = 'day'; }; }
|
|
207
|
-
head.appendChild(h);
|
|
208
|
-
}
|
|
209
|
-
grid.appendChild(head);
|
|
287
|
+
gridCols.value = String(defs.length);
|
|
288
|
+
gridH.value = px((to - from) * PX_PER_MIN);
|
|
210
289
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
t.textContent = fromMinutes(m);
|
|
220
|
-
gutter.appendChild(t);
|
|
221
|
-
}
|
|
222
|
-
bodyRow.appendChild(gutter);
|
|
290
|
+
heads.value = defs.map(c => ({
|
|
291
|
+
key: c.key, title: c.title, kind: c.kind || '', colour: c.colour || false,
|
|
292
|
+
cls: [c.today && week ? 'is-today' : '', week ? 'bk-link-head' : ''].filter(Boolean).join(' '),
|
|
293
|
+
date: week ? c.date : false, tip: week ? 'Show this day' : false
|
|
294
|
+
}));
|
|
295
|
+
const gutter = [];
|
|
296
|
+
for (let m = from; m < to; m += 60) gutter.push({id: m, top: px((m - from) * PX_PER_MIN), text: fromMinutes(m)});
|
|
297
|
+
hours.value = gutter;
|
|
223
298
|
|
|
224
299
|
const nowWall = wallClock(Date.now(), ctx.tz);
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
col.dataset.date = c.date;
|
|
229
|
-
if (c.bookableId) col.dataset.bookable = c.bookableId;
|
|
230
|
-
col.title = 'Click an empty time to book it';
|
|
231
|
-
for (const w of c.windows) {
|
|
232
|
-
const band = document.createElement('div');
|
|
233
|
-
band.className = 'bk-open';
|
|
300
|
+
cols.value = defs.map(c => {
|
|
301
|
+
const windows = c.windows.map((w, i) => {
|
|
302
|
+
// The band keeps its hour lines on the column's.
|
|
234
303
|
const bandTop = (Math.max(w.from, from) - from) * PX_PER_MIN;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
band.style.height = `${(Math.min(w.to, to) - Math.max(w.from, from)) * PX_PER_MIN}px`;
|
|
239
|
-
col.appendChild(band);
|
|
240
|
-
}
|
|
241
|
-
if (c.date === nowWall.date && nowWall.minutes >= from && nowWall.minutes <= to) {
|
|
242
|
-
const line = document.createElement('div');
|
|
243
|
-
line.className = 'bk-now';
|
|
244
|
-
line.style.top = `${(nowWall.minutes - from) * PX_PER_MIN}px`;
|
|
245
|
-
col.appendChild(line);
|
|
246
|
-
}
|
|
304
|
+
return {id: `w${i}`, top: px(bandTop), bgY: px(-bandTop), height: px((Math.min(w.to, to) - Math.max(w.from, from)) * PX_PER_MIN)};
|
|
305
|
+
});
|
|
306
|
+
const nowTop = c.date === nowWall.date && nowWall.minutes >= from && nowWall.minutes <= to ? px((nowWall.minutes - from) * PX_PER_MIN) : '';
|
|
247
307
|
const dayBookings = placed.filter(p => p.date === c.date).map(p => p.b);
|
|
248
308
|
const items = columnItems(dayBookings, {bookableId: c.bookableId || ''}).map(it => ({...it, ...placeOf(it.b, ctx.tz)}));
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
el.className = `bk-block is-${b.status}${p.companion ? ' is-companion' : ''}${p.session ? ' is-session' : ''}`;
|
|
253
|
-
if (p.session) {
|
|
254
|
-
sessions.set(p.key, p.bookings);
|
|
255
|
-
el.dataset.id = `session:${p.key}`;
|
|
256
|
-
} else {
|
|
257
|
-
el.dataset.id = b.id;
|
|
258
|
-
}
|
|
259
|
-
el.dataset.colour = colours.get(b.bookableId) || 'grey';
|
|
260
|
-
el.tabIndex = 0;
|
|
261
|
-
el.setAttribute('role', 'button');
|
|
262
|
-
const top = (Math.max(p.startMin, from) - from) * PX_PER_MIN;
|
|
263
|
-
el.style.top = `${top}px`;
|
|
264
|
-
el.style.height = `${Math.max(18, (Math.min(p.endMin, to) - Math.max(p.startMin, from)) * PX_PER_MIN - 2)}px`;
|
|
265
|
-
el.style.left = `calc(${(p.lane / p.lanes) * 100}% + 2px)`;
|
|
266
|
-
el.style.width = `calc(${100 / p.lanes}% - 4px)`;
|
|
267
|
-
const s = wallClock(b.startAt, ctx.tz).time;
|
|
268
|
-
const e = wallClock(b.endAt, ctx.tz).time;
|
|
269
|
-
const time = view.value === 'week' ? esc(s) : `${esc(s)}-${esc(e)}`;
|
|
270
|
-
const statusNote = b.status !== 'confirmed' ? ` · ${esc({completed: 'done', 'no-show': 'no-show', cancelled: 'cancelled', pending: 'pending'}[b.status] || b.status)}` : '';
|
|
271
|
-
if (p.session) {
|
|
272
|
-
// A class: one block for everyone in it.
|
|
273
|
-
el.innerHTML = `<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
274
|
-
<strong>${time} ${esc(b.serviceName)} · ${p.bookings.length} booked</strong><small>${p.companion ? `with ${esc(b.bookableName)}` : esc(p.bookings.map(x => x.name).join(', '))}${view.value === 'week' && b.bookableName ? ` · ${esc(b.bookableName)}` : ''}</small>`;
|
|
275
|
-
el.title = `${s}-${e} ${b.serviceName}: ${p.bookings.length} booked${b.bookableName ? ` (with ${withNames(b)})` : ''}`;
|
|
276
|
-
} else if (p.companion) {
|
|
277
|
-
// Held along with someone else's booking (a court with the trainer).
|
|
278
|
-
el.innerHTML = `<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
279
|
-
<strong>${time} ${esc(b.name)}</strong><small>${esc(b.serviceName)} · with ${esc(b.bookableName)}${statusNote}</small>`;
|
|
280
|
-
el.title = `${s}-${e} ${b.serviceName}: ${b.name} - held with ${b.bookableName}'s booking`;
|
|
281
|
-
} else {
|
|
282
|
-
el.innerHTML = `<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
283
|
-
<strong>${time} ${esc(b.name)}</strong><small>${esc(b.serviceName)}${view.value === 'week' && b.bookableName ? ` · ${esc(withNames(b))}` : b.alsoNames?.length ? ` · with ${esc(b.alsoNames.join(', '))}` : ''}${statusNote}</small>`;
|
|
284
|
-
el.title = `${s}-${e} ${b.serviceName}: ${b.name}${withNames(b) ? ` (with ${withNames(b)})` : ''}`;
|
|
285
|
-
}
|
|
286
|
-
col.appendChild(el);
|
|
287
|
-
}
|
|
288
|
-
bodyRow.appendChild(col);
|
|
289
|
-
}
|
|
290
|
-
grid.appendChild(bodyRow);
|
|
291
|
-
gridBox.appendChild(grid);
|
|
292
|
-
if (!boot.services.length) {
|
|
293
|
-
const tip = document.createElement('p');
|
|
294
|
-
tip.className = 'bk-note';
|
|
295
|
-
tip.innerHTML = 'Next: <button type="button" class="bk-link">add a service</button> - what can be booked, for how long, and with whom.';
|
|
296
|
-
tip.querySelector('button').onclick = () => ctx.go('services', 'new');
|
|
297
|
-
gridBox.prepend(tip);
|
|
298
|
-
}
|
|
309
|
+
const blocks = layoutColumn(items).map(p => blockRow(p, {from, to, week, colours}));
|
|
310
|
+
return {key: c.key, date: c.date, bookableId: c.bookableId || false, windows, nowTop, blocks};
|
|
311
|
+
});
|
|
299
312
|
}
|
|
300
313
|
scope(M.effect(draw));
|
|
301
314
|
|
|
@@ -360,15 +373,3 @@ export async function mount(root, ctx) {
|
|
|
360
373
|
|
|
361
374
|
return () => {};
|
|
362
375
|
}
|
|
363
|
-
|
|
364
|
-
function mergeWindows(list) {
|
|
365
|
-
const sorted = [...list].sort((a, b) => a.from - b.from);
|
|
366
|
-
const out = [];
|
|
367
|
-
for (const w of sorted) {
|
|
368
|
-
const last = out[out.length - 1];
|
|
369
|
-
if (last && w.from <= last.to) last.to = Math.max(last.to, w.to);
|
|
370
|
-
else out.push({...w});
|
|
371
|
-
}
|
|
372
|
-
return out;
|
|
373
|
-
}
|
|
374
|
-
|
|
@@ -22,6 +22,31 @@ const ROW = `
|
|
|
22
22
|
<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
23
23
|
</div>`;
|
|
24
24
|
|
|
25
|
+
// One weekday in the editor: its windows (a keyed list of their own) or "Closed".
|
|
26
|
+
const DAY_ROW = `
|
|
27
|
+
<div class="bk-day">
|
|
28
|
+
<span class="bk-day-name" data-bind-text="name"></span>
|
|
29
|
+
<div class="bk-windows">
|
|
30
|
+
<span class="bk-muted" data-bind-hidden="windows.value.length">Closed</span>
|
|
31
|
+
{{#each windows.value key=id}}<span class="bk-window">
|
|
32
|
+
<input type="time" class="form-input" step="300" data-model="from.value" data-bind-aria-label="$parent.name + ' from'">
|
|
33
|
+
<span>-</span>
|
|
34
|
+
<input type="time" class="form-input" step="300" data-model="to.value" data-bind-aria-label="$parent.name + ' to'">
|
|
35
|
+
<button type="button" class="btn btn-xs btn-ghost bk-x" title="Remove these hours" aria-label="Remove these hours" data-on-click="$root.dropWindow($parent, $data)"><span data-icon="x" data-icon-size="12"></span></button></span>{{/each}}
|
|
36
|
+
</div>
|
|
37
|
+
<button type="button" class="btn btn-xs btn-ghost" data-bind-title="'Add hours on ' + name" data-on-click="$parent.addWindow($data)"><span data-icon="plus" data-icon-size="12"></span> Hours</button>
|
|
38
|
+
</div>`;
|
|
39
|
+
|
|
40
|
+
// One spell of days off.
|
|
41
|
+
const OFF_ROW = `
|
|
42
|
+
<div class="bk-off">
|
|
43
|
+
<input type="date" class="form-input" data-model="from.value" aria-label="From" data-on-change="$parent.offFrom($data)">
|
|
44
|
+
<span>to</span>
|
|
45
|
+
<input type="date" class="form-input" data-model="to.value" aria-label="To">
|
|
46
|
+
<input class="form-input" data-model="label.value" placeholder="Holiday, training…" maxlength="80" aria-label="Why">
|
|
47
|
+
<button type="button" class="btn btn-xs btn-ghost bk-x" title="Remove" aria-label="Remove" data-on-click="$parent.dropOff($data)"><span data-icon="x" data-icon-size="12"></span></button>
|
|
48
|
+
</div>`;
|
|
49
|
+
|
|
25
50
|
/** "Mon-Fri 09:00-17:00" style summary of weekly hours. */
|
|
26
51
|
export function hoursSummary(hours) {
|
|
27
52
|
const txt = (d) => (hours?.[d] || []).map(w => `${w.from}-${w.to}`).join(', ');
|
|
@@ -123,43 +148,43 @@ export async function mount(root, ctx) {
|
|
|
123
148
|
const f = {};
|
|
124
149
|
for (const k of ['name', 'kind', 'description', 'userId', 'email', 'colour']) f[k] = M.observable(start[k] ?? '');
|
|
125
150
|
const bools = {calendarBusy: M.observable(start.calendarBusy !== false), active: M.observable(start.active !== false)};
|
|
126
|
-
|
|
127
|
-
|
|
151
|
+
// Hours and days off are keyed lists (see DAY_ROW / OFF_ROW): a day's
|
|
152
|
+
// windows live in an observable so adding or removing one redraws only
|
|
153
|
+
// that day; each field is an observable the inputs write through.
|
|
154
|
+
let seq = 0;
|
|
155
|
+
const windowRow = (w) => ({id: `w${++seq}`, from: M.observable(w.from || ''), to: M.observable(w.to || '')});
|
|
156
|
+
const offRow = (o) => ({id: `o${++seq}`, from: M.observable(o.from || ''), to: M.observable(o.to || ''), label: M.observable(o.label || '')});
|
|
157
|
+
const days = M.observable(WEEK.map(d => ({id: d, name: DAY_NAMES[d], windows: M.observable((start.hours?.[d] || []).map(windowRow))})));
|
|
158
|
+
const daysOff = M.observable((start.daysOff || []).map(offRow));
|
|
159
|
+
const hoursOut = () => Object.fromEntries(days.peek().map(d => [d.id, d.windows.peek().map(w => ({from: w.from.peek(), to: w.to.peek()}))]));
|
|
160
|
+
const daysOffOut = () => daysOff.peek().map(o => ({from: o.from.peek(), to: o.to.peek(), label: o.label.peek()}));
|
|
128
161
|
const isPerson = M.computed(() => f.kind.value === 'person');
|
|
129
162
|
const saving = M.observable(false);
|
|
130
|
-
const snapshot = () => JSON.stringify([Object.values(f).map(o => o.peek()), Object.values(bools).map(o => o.peek()),
|
|
163
|
+
const snapshot = () => JSON.stringify([Object.values(f).map(o => o.peek()), Object.values(bools).map(o => o.peek()), hoursOut(), daysOffOut()]);
|
|
131
164
|
const opened = snapshot();
|
|
132
165
|
let panel = null;
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
const box = el.querySelector('[data-part="hours"]');
|
|
136
|
-
box.innerHTML = WEEK.map(d => `<div class="bk-day" data-day="${d}">
|
|
137
|
-
<span class="bk-day-name">${DAY_NAMES[d]}</span>
|
|
138
|
-
<div class="bk-windows">${hours[d].length ? hours[d].map((w, i) => `<span class="bk-window">
|
|
139
|
-
<input type="time" class="form-input" step="300" value="${esc(w.from)}" data-i="${i}" data-k="from" aria-label="${DAY_NAMES[d]} from">
|
|
140
|
-
<span>-</span>
|
|
141
|
-
<input type="time" class="form-input" step="300" value="${esc(w.to)}" data-i="${i}" data-k="to" aria-label="${DAY_NAMES[d]} to">
|
|
142
|
-
<button type="button" class="btn btn-xs btn-ghost bk-x" data-drop="${i}" title="Remove these hours" aria-label="Remove these hours"><span data-icon="x" data-icon-size="12"></span></button></span>`).join('') : '<span class="bk-muted">Closed</span>'}</div>
|
|
143
|
-
<button type="button" class="btn btn-xs btn-ghost" data-add title="Add hours on ${DAY_NAMES[d]}"><span data-icon="plus" data-icon-size="12"></span> Hours</button>
|
|
144
|
-
</div>`).join('');
|
|
145
|
-
I.scan(box);
|
|
146
|
-
};
|
|
147
|
-
const drawDaysOff = (el) => {
|
|
148
|
-
const box = el.querySelector('[data-part="daysoff"]');
|
|
149
|
-
box.innerHTML = daysOff.length ? daysOff.map((o, i) => `<div class="bk-off" data-i="${i}">
|
|
150
|
-
<input type="date" class="form-input" value="${esc(o.from)}" data-k="from" aria-label="From">
|
|
151
|
-
<span>to</span>
|
|
152
|
-
<input type="date" class="form-input" value="${esc(o.to)}" data-k="to" aria-label="To">
|
|
153
|
-
<input class="form-input" value="${esc(o.label)}" data-k="label" placeholder="Holiday, training…" maxlength="80" aria-label="Why">
|
|
154
|
-
<button type="button" class="btn btn-xs btn-ghost bk-x" data-drop title="Remove" aria-label="Remove"><span data-icon="x" data-icon-size="12"></span></button></div>`).join('')
|
|
155
|
-
: '<p class="bk-note">None. Add holidays and other days they cannot be booked.</p>';
|
|
156
|
-
I.scan(box);
|
|
157
|
-
};
|
|
166
|
+
// New rows carry icons; they are drawn once the list has rendered.
|
|
167
|
+
const rescan = () => { M.flushSync?.(); if (panel) { I.scan(panel.panel); requestAnimationFrame(() => panel?.panel.isConnected && I.scan(panel.panel)); } };
|
|
158
168
|
|
|
159
169
|
panel = await openPanel({
|
|
160
170
|
title: b ? `Edit ${b.name}` : 'Add a person or room',
|
|
161
171
|
size: 'lg',
|
|
162
|
-
model: {
|
|
172
|
+
model: {
|
|
173
|
+
...f, saving, isPerson, days, daysOff,
|
|
174
|
+
addWindow(day) {
|
|
175
|
+
const list = day.windows.peek();
|
|
176
|
+
const last = list[list.length - 1];
|
|
177
|
+
day.windows.value = [...list, windowRow(last ? {from: last.to.peek(), to: last.to.peek() < '17:00' ? '17:00' : last.to.peek()} : {from: '09:00', to: '17:00'})];
|
|
178
|
+
rescan();
|
|
179
|
+
},
|
|
180
|
+
dropWindow(day, w) { day.windows.value = day.windows.peek().filter(x => x !== w); },
|
|
181
|
+
dropOff(o) { daysOff.value = daysOff.peek().filter(x => x !== o); },
|
|
182
|
+
// An end before the start is never meant: it follows the start.
|
|
183
|
+
offFrom(o, e) {
|
|
184
|
+
const v = e?.target?.value ?? o.from.peek();
|
|
185
|
+
if (!o.to.peek() || o.to.peek() < v) o.to.value = v;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
163
188
|
dirty: () => snapshot() !== opened,
|
|
164
189
|
html: `<div class="bk-form">
|
|
165
190
|
<div class="bk-grid">
|
|
@@ -182,10 +207,11 @@ export async function mount(root, ctx) {
|
|
|
182
207
|
</div>
|
|
183
208
|
</div>
|
|
184
209
|
<h4 class="bk-sub">Opening hours <span data-help-title="Opening hours" data-help="When they can be booked, in ${esc(ctx.tz)}. Add several windows for a day with a break (09:00-12:30, 13:30-17:00). A day with no hours is closed."></span></h4>
|
|
185
|
-
<div class="bk-hours" data-
|
|
210
|
+
<div class="bk-hours" data-each="days.value key=id">${DAY_ROW}</div>
|
|
186
211
|
<div class="bk-actions bk-actions-start"><button type="button" class="btn btn-xs btn-outline" data-on-click="copyMonday"><span data-icon="copy" data-icon-size="12"></span> Copy Monday to Tuesday-Friday</button></div>
|
|
187
212
|
<h4 class="bk-sub">Days off <span data-help-title="Days off" data-help="Holidays and other days they cannot be booked. For days the whole business is closed, use Closed days in Booking settings."></span></h4>
|
|
188
|
-
<
|
|
213
|
+
<p class="bk-note" data-bind-hidden="daysOff.value.length">None. Add holidays and other days they cannot be booked.</p>
|
|
214
|
+
<div data-each="daysOff.value key=id">${OFF_ROW}</div>
|
|
189
215
|
<div class="bk-actions bk-actions-start"><button type="button" class="btn btn-xs btn-outline" data-on-click="addOff"><span data-icon="plus" data-icon-size="12"></span> Add days off</button></div>
|
|
190
216
|
<div class="bk-actions"><span class="bk-spacer"></span>
|
|
191
217
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
@@ -196,52 +222,24 @@ export async function mount(root, ctx) {
|
|
|
196
222
|
box.checked = bools[box.dataset.bool].peek();
|
|
197
223
|
box.addEventListener('change', () => { bools[box.dataset.bool].value = box.checked; });
|
|
198
224
|
}
|
|
199
|
-
drawHours(el);
|
|
200
|
-
drawDaysOff(el);
|
|
201
|
-
const hoursBox = el.querySelector('[data-part="hours"]');
|
|
202
|
-
hoursBox.addEventListener('input', (e) => {
|
|
203
|
-
const day = e.target.closest('[data-day]')?.dataset.day;
|
|
204
|
-
if (!day || e.target.dataset.k === undefined) return;
|
|
205
|
-
hours[day][Number(e.target.dataset.i)][e.target.dataset.k] = e.target.value;
|
|
206
|
-
});
|
|
207
|
-
hoursBox.addEventListener('click', (e) => {
|
|
208
|
-
const day = e.target.closest('[data-day]')?.dataset.day;
|
|
209
|
-
if (!day) return;
|
|
210
|
-
const drop = e.target.closest('[data-drop]');
|
|
211
|
-
if (drop) { hours[day].splice(Number(drop.dataset.drop), 1); drawHours(el); return; }
|
|
212
|
-
if (e.target.closest('[data-add]')) {
|
|
213
|
-
const last = hours[day][hours[day].length - 1];
|
|
214
|
-
hours[day].push(last ? {from: last.to, to: last.to < '17:00' ? '17:00' : last.to} : {from: '09:00', to: '17:00'});
|
|
215
|
-
drawHours(el);
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
const offBox = el.querySelector('[data-part="daysoff"]');
|
|
219
|
-
offBox.addEventListener('input', (e) => {
|
|
220
|
-
const row = e.target.closest('[data-i]');
|
|
221
|
-
if (!row) return;
|
|
222
|
-
const o = daysOff[Number(row.dataset.i)];
|
|
223
|
-
o[e.target.dataset.k] = e.target.value;
|
|
224
|
-
if (e.target.dataset.k === 'from' && (!o.to || o.to < o.from)) { o.to = o.from; row.querySelector('[data-k="to"]').value = o.from; }
|
|
225
|
-
});
|
|
226
|
-
offBox.addEventListener('click', (e) => {
|
|
227
|
-
const row = e.target.closest('[data-i]');
|
|
228
|
-
if (row && e.target.closest('[data-drop]')) { daysOff.splice(Number(row.dataset.i), 1); drawDaysOff(el); }
|
|
229
|
-
});
|
|
230
225
|
},
|
|
231
226
|
after: (el) => setTimeout(() => el.querySelector('input')?.focus(), 60),
|
|
232
227
|
methods: {
|
|
233
228
|
cancel: () => panel?.close(),
|
|
234
229
|
copyMonday() {
|
|
235
|
-
|
|
236
|
-
|
|
230
|
+
const byId = Object.fromEntries(days.peek().map(d => [d.id, d]));
|
|
231
|
+
for (const d of ['tue', 'wed', 'thu', 'fri']) byId[d].windows.value = byId.mon.windows.peek().map(w => windowRow({from: w.from.peek(), to: w.to.peek()}));
|
|
232
|
+
rescan();
|
|
237
233
|
},
|
|
238
234
|
addOff() {
|
|
239
|
-
daysOff = [...daysOff, {from: boot.today, to: boot.today, label: ''}];
|
|
240
|
-
|
|
235
|
+
daysOff.value = [...daysOff.peek(), offRow({from: boot.today, to: boot.today, label: ''})];
|
|
236
|
+
rescan();
|
|
241
237
|
},
|
|
242
238
|
async save() {
|
|
243
239
|
if (saving.peek()) return;
|
|
244
240
|
// Say which hours are wrong rather than let the server drop them quietly.
|
|
241
|
+
const hours = hoursOut();
|
|
242
|
+
const daysOff = daysOffOut();
|
|
245
243
|
for (const d of DAY_KEYS) {
|
|
246
244
|
for (const w of hours[d]) {
|
|
247
245
|
if (!(toMinutes(w.to) > toMinutes(w.from))) { E.toast(`${DAY_NAMES[d]}: ${w.from || '?'}-${w.to || '?'} - the end must be after the start.`, {type: 'warning'}); return; }
|