domma-cms 0.102.1 → 0.103.0
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/package.json +1 -1
- package/plugins/_lib/admin/mail/message-actions.js +56 -0
- package/plugins/_lib/admin/mail/reader-view.js +30 -1
- package/plugins/_lib/call/ice.js +58 -0
- package/plugins/_lib/call/rooms.js +232 -0
- package/plugins/_lib/call/routes.js +88 -0
- package/plugins/_lib/public/call/call.css +1 -0
- package/plugins/_lib/public/call/call.js +468 -0
- package/plugins/booking/CLAUDE.md +123 -0
- package/plugins/booking/admin/css/index.css +192 -0
- package/plugins/booking/admin/lib/details.js +58 -0
- package/plugins/booking/admin/lib/diary-layout.js +140 -0
- package/plugins/booking/admin/lib/extension-sections.js +160 -0
- package/plugins/booking/admin/lib/kit.js +249 -0
- package/plugins/booking/admin/templates/booking.html +1 -0
- package/plugins/booking/admin/views/booking.js +177 -0
- package/plugins/booking/admin/views/bookings.js +127 -0
- package/plugins/booking/admin/views/diary.js +374 -0
- package/plugins/booking/admin/views/panels.js +516 -0
- package/plugins/booking/admin/views/people.js +281 -0
- package/plugins/booking/admin/views/services.js +222 -0
- package/plugins/booking/admin/views/settings.js +203 -0
- package/plugins/booking/collections/booking-bookables.json +17 -0
- package/plugins/booking/collections/booking-bookings.json +18 -0
- package/plugins/booking/collections/booking-services.json +17 -0
- package/plugins/booking/config.js +36 -0
- package/plugins/booking/docs/guide.md +69 -0
- package/plugins/booking/inject/body.html +1 -0
- package/plugins/booking/lib/call.js +58 -0
- package/plugins/booking/lib/extensions.js +150 -0
- package/plugins/booking/lib/mail.js +113 -0
- package/plugins/booking/lib/store.js +384 -0
- package/plugins/booking/plugin.js +760 -0
- package/plugins/booking/plugin.json +80 -0
- package/plugins/booking/plugin.public.js +157 -0
- package/plugins/booking/public/booking.css +159 -0
- package/plugins/booking/public/booking.js +808 -0
- package/plugins/booking/public/lib/clean.js +141 -0
- package/plugins/booking/public/lib/page-format.js +121 -0
- package/plugins/booking/public/lib/slots.js +247 -0
- package/plugins/booking/public/lib/time.js +131 -0
- package/plugins/booking/templates/booking.html +5 -0
- package/plugins/booking/tests/admin-diary.test.js +73 -0
- package/plugins/booking/tests/api.test.js +356 -0
- package/plugins/booking/tests/public.test.js +71 -0
- package/plugins/booking/tests/pure.test.js +189 -0
- package/plugins/free-tier.lock.json +44 -1
- package/server/routes/api/related.js +20 -5
- package/server/server.js +3 -3
- package/server/services/hooks.js +35 -8
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking › the slideovers every screen shares: one booking (details, status,
|
|
3
|
+
* staff note, history), a new booking, moving one, cancelling one - and the
|
|
4
|
+
* right-click / ⋮ menu a booking has wherever it is shown (Diary, Bookings).
|
|
5
|
+
*
|
|
6
|
+
* A clash is a 409 from the server; staff are asked "Book it anyway?" and the
|
|
7
|
+
* request goes again with `force`. "Email the customer" is the `notify` flag:
|
|
8
|
+
* without it, a change made by staff sends nothing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
STATUS_LABEL, STATUS_TONE, api, dateLabel, errText, esc, fmtDateTime, isClash, minutesLabel, openPanel, refreshBadge
|
|
13
|
+
} from '../lib/kit.js';
|
|
14
|
+
import {atWallClock, isDate, toMinutes, wallClock} from '/plugins/booking/public/lib/time.js';
|
|
15
|
+
import {answerRows, placesLabel, sourceLabel, withNames} from '../lib/details.js';
|
|
16
|
+
import {mountSections} from '../lib/extension-sections.js';
|
|
17
|
+
|
|
18
|
+
/** Places per session a service takes (Booking Pro's classes); 1 for an appointment. */
|
|
19
|
+
const capacityOf = (svc) => Math.max(1, Number(svc?.ext?.['booking-pro']?.capacity) || 1);
|
|
20
|
+
|
|
21
|
+
const changed = (ctx) => { refreshBadge(); try { ctx.onBookingsChanged?.(); } catch { /* the screen went */ } };
|
|
22
|
+
|
|
23
|
+
/** "Tue 15/07/2031, 09:00-10:00" in the business zone. */
|
|
24
|
+
export function timeRange(b, tz) {
|
|
25
|
+
const s = wallClock(b.startAt, tz);
|
|
26
|
+
const e = wallClock(b.endAt, tz);
|
|
27
|
+
return `${dateLabel(s.date)}, ${s.time}-${e.time}${e.date !== s.date ? ` (${dateLabel(e.date)})` : ''}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---- The menu --------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The menu a booking has everywhere. `reload` is told after any change.
|
|
34
|
+
*
|
|
35
|
+
* @returns {object[]} E.contextMenu items
|
|
36
|
+
*/
|
|
37
|
+
export function bookingMenu(ctx, b) {
|
|
38
|
+
if (!b) return [];
|
|
39
|
+
const live = b.status !== 'cancelled';
|
|
40
|
+
return [
|
|
41
|
+
{label: 'Open', icon: 'external-link', action: () => openBooking(ctx, b.id)},
|
|
42
|
+
{label: 'Move…', icon: 'move', disabled: () => !live, action: () => moveBooking(ctx, b)},
|
|
43
|
+
{type: 'divider'},
|
|
44
|
+
{label: 'Mark as done', icon: 'check', disabled: () => !live || b.status === 'completed', action: () => setStatus(ctx, b, 'completed')},
|
|
45
|
+
{label: 'Mark as no-show', icon: 'user-remove', disabled: () => !live || b.status === 'no-show', action: () => setStatus(ctx, b, 'no-show')},
|
|
46
|
+
{label: 'Back to booked', icon: 'rotate-ccw', disabled: () => !live || b.status === 'confirmed', action: () => setStatus(ctx, b, 'confirmed')},
|
|
47
|
+
{type: 'divider'},
|
|
48
|
+
{label: 'Email the customer', icon: 'mail', disabled: () => !b.email, action: () => { location.href = `mailto:${encodeURIComponent(b.email)}?subject=${encodeURIComponent(`Your booking: ${b.serviceName}`)}`; }},
|
|
49
|
+
...(b.contactId && ctx.boot.contacts ? [{label: 'Open Contacts', icon: 'users', action: () => { location.hash = '#/contacts'; }}] : []),
|
|
50
|
+
{type: 'divider'},
|
|
51
|
+
live
|
|
52
|
+
? {label: 'Cancel booking…', icon: 'x-circle', action: () => cancelBooking(ctx, b)}
|
|
53
|
+
: {label: 'Delete', icon: 'trash', action: () => deleteBooking(ctx, b)}
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- Status, cancel, delete ------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export async function setStatus(ctx, b, status) {
|
|
60
|
+
try {
|
|
61
|
+
const next = await api.bookings.update(b.id, {status});
|
|
62
|
+
E.toast(`${b.name}: ${STATUS_LABEL[status].toLowerCase()}.`, {type: 'success'});
|
|
63
|
+
changed(ctx);
|
|
64
|
+
return next;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
E.toast(`Not changed: ${errText(err)}`, {type: 'error'});
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Cancel, with a reason and whether to email the customer. Resolves the booking, or null. */
|
|
72
|
+
export function cancelBooking(ctx, b) {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const reason = M.observable('');
|
|
75
|
+
const notify = M.observable(Boolean(b.email) && ctx.boot.mailReady);
|
|
76
|
+
let panel = null;
|
|
77
|
+
let done = null;
|
|
78
|
+
openPanel({
|
|
79
|
+
title: 'Cancel booking',
|
|
80
|
+
size: 'sm',
|
|
81
|
+
model: {reason, notify},
|
|
82
|
+
html: `<div class="bk-form">
|
|
83
|
+
<p><strong>${esc(b.serviceName)}</strong> for <strong>${esc(b.name)}</strong><br><span class="bk-muted">${esc(timeRange(b, ctx.tz))}</span></p>
|
|
84
|
+
<p class="bk-note">The time becomes free for someone else straight away.</p>
|
|
85
|
+
<label class="form-label">Reason <span class="bk-muted">(optional)</span> <span data-help-title="Reason" data-help="Kept in the booking's history. It is not sent to the customer."></span></label>
|
|
86
|
+
<textarea class="form-input" rows="2" maxlength="300" data-model="reason.value"></textarea>
|
|
87
|
+
<label class="bk-check"><input type="checkbox" data-bool="notify" ${b.email ? '' : 'disabled'}> Email the customer that it is cancelled
|
|
88
|
+
<span data-help-title="Email the customer" data-help="They get a short cancellation email with a calendar file that takes it off their calendar. Off: nothing is sent - tell them yourself."></span></label>
|
|
89
|
+
${ctx.boot.mailReady ? '' : '<p class="bk-note is-warn">This site has no outgoing mail server set up, so no email can be sent.</p>'}
|
|
90
|
+
<div class="bk-actions"><span class="bk-spacer"></span>
|
|
91
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="keep">Keep it</button>
|
|
92
|
+
<button type="button" class="btn btn-sm btn-danger" data-on-click="save"><span data-icon="x-circle" data-icon-size="13"></span> Cancel booking</button></div>
|
|
93
|
+
</div>`,
|
|
94
|
+
before: (el) => {
|
|
95
|
+
const box = el.querySelector('[data-bool="notify"]');
|
|
96
|
+
box.checked = notify.peek();
|
|
97
|
+
box.addEventListener('change', () => { notify.value = box.checked; });
|
|
98
|
+
},
|
|
99
|
+
cleanup: () => resolve(done),
|
|
100
|
+
methods: {
|
|
101
|
+
keep: () => panel?.close(true),
|
|
102
|
+
async save() {
|
|
103
|
+
try {
|
|
104
|
+
done = await api.bookings.update(b.id, {status: 'cancelled', reason: reason.peek(), notify: notify.peek()});
|
|
105
|
+
E.toast(`Cancelled${notify.peek() ? ' - the customer has been emailed' : ''}.`, {type: 'success'});
|
|
106
|
+
changed(ctx);
|
|
107
|
+
panel?.close(true);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
E.toast(`Not cancelled: ${errText(err)}`, {type: 'error'});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}).then(p => { panel = p; });
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function deleteBooking(ctx, b) {
|
|
118
|
+
if (!await E.confirm(`Delete the cancelled booking for <strong>${esc(b.name)}</strong> (${esc(timeRange(b, ctx.tz))})? It cannot be brought back.`)) return false;
|
|
119
|
+
try {
|
|
120
|
+
await api.bookings.remove(b.id);
|
|
121
|
+
E.toast('Deleted.', {type: 'success'});
|
|
122
|
+
changed(ctx);
|
|
123
|
+
return true;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
E.toast(`Not deleted: ${errText(err)}`, {type: 'error'});
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- Choosing a time (new and move share it) -------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The date + free times + "or type a time" block, filled for a service and
|
|
134
|
+
* (optionally) one person or room. Plain DOM - it is redrawn whole on each
|
|
135
|
+
* change and holds no bindings.
|
|
136
|
+
*
|
|
137
|
+
* @returns {{el: HTMLElement, load: Function, value: () => {date: string, time: string}, set: Function}}
|
|
138
|
+
*/
|
|
139
|
+
function timePicker(ctx, {date, time = '', ignore = ''}) {
|
|
140
|
+
const el = document.createElement('div');
|
|
141
|
+
el.className = 'bk-picker';
|
|
142
|
+
el.innerHTML = `
|
|
143
|
+
<div class="bk-grid">
|
|
144
|
+
<div><label class="form-label">Date <span data-help-title="Date" data-help="The free times that day are listed below, in this business's time zone. Staff are not held to the service's notice or how-far-ahead rules."></span></label>
|
|
145
|
+
<input type="date" class="form-input" data-part="date"></div>
|
|
146
|
+
<div><label class="form-label">Time <span data-help-title="Time" data-help="Pick a free time below, or type any time (24-hour, e.g. 14:30). A typed time that clashes asks before booking over it."></span></label>
|
|
147
|
+
<input type="time" class="form-input" step="300" data-part="time"></div>
|
|
148
|
+
</div>
|
|
149
|
+
<div class="bk-slots" data-part="slots"><p class="bk-note">Pick a service to see free times.</p></div>`;
|
|
150
|
+
const dateIn = el.querySelector('[data-part="date"]');
|
|
151
|
+
const timeIn = el.querySelector('[data-part="time"]');
|
|
152
|
+
const box = el.querySelector('[data-part="slots"]');
|
|
153
|
+
dateIn.value = date || ctx.boot.today;
|
|
154
|
+
timeIn.value = time;
|
|
155
|
+
let current = {service: '', with: ''};
|
|
156
|
+
let seq = 0;
|
|
157
|
+
|
|
158
|
+
const mark = () => {
|
|
159
|
+
for (const b of box.querySelectorAll('[data-time]')) b.classList.toggle('is-on', b.dataset.time === timeIn.value);
|
|
160
|
+
};
|
|
161
|
+
async function load(q = current) {
|
|
162
|
+
current = q;
|
|
163
|
+
const my = ++seq;
|
|
164
|
+
if (!q.service) { box.innerHTML = '<p class="bk-note">Pick a service to see free times.</p>'; return; }
|
|
165
|
+
if (!isDate(dateIn.value)) { box.innerHTML = '<p class="bk-note">Pick a date.</p>'; return; }
|
|
166
|
+
box.innerHTML = '<p class="bk-note">Finding free times…</p>';
|
|
167
|
+
try {
|
|
168
|
+
const r = await api.slots({service: q.service, with: q.with, from: dateIn.value, to: dateIn.value, ignore});
|
|
169
|
+
if (my !== seq) return;
|
|
170
|
+
const slots = r.days?.[0]?.slots || [];
|
|
171
|
+
if (!slots.length) {
|
|
172
|
+
box.innerHTML = '<p class="bk-note">No free times that day (closed, or all booked). Type a time above to book anyway.</p>';
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
box.innerHTML = '';
|
|
176
|
+
// A class shows its places; an appointment (one place) does not need to.
|
|
177
|
+
const svc = ctx.boot.services.find(x => x.id === q.service);
|
|
178
|
+
const classy = capacityOf(svc) > 1 || slots.some(x => Number(x.left) > 1);
|
|
179
|
+
for (const s of slots) {
|
|
180
|
+
const b = document.createElement('button');
|
|
181
|
+
b.type = 'button';
|
|
182
|
+
b.className = 'bk-slot';
|
|
183
|
+
b.dataset.time = s.time;
|
|
184
|
+
b.textContent = s.time;
|
|
185
|
+
if (classy && Number.isFinite(Number(s.left))) {
|
|
186
|
+
const small = document.createElement('small');
|
|
187
|
+
small.textContent = placesLabel(s.left);
|
|
188
|
+
b.appendChild(small);
|
|
189
|
+
b.title = `${s.time} - ${placesLabel(s.left)}`;
|
|
190
|
+
}
|
|
191
|
+
box.appendChild(b);
|
|
192
|
+
}
|
|
193
|
+
mark();
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (my === seq) box.innerHTML = `<p class="bk-note is-bad">${esc(errText(err))}</p>`;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
box.addEventListener('click', (e) => {
|
|
199
|
+
const b = e.target.closest('[data-time]');
|
|
200
|
+
if (!b) return;
|
|
201
|
+
timeIn.value = b.dataset.time;
|
|
202
|
+
mark();
|
|
203
|
+
timeIn.dispatchEvent(new Event('input', {bubbles: true}));
|
|
204
|
+
});
|
|
205
|
+
dateIn.addEventListener('change', () => load());
|
|
206
|
+
timeIn.addEventListener('input', mark);
|
|
207
|
+
return {
|
|
208
|
+
el, load,
|
|
209
|
+
value: () => ({date: dateIn.value, time: timeIn.value}),
|
|
210
|
+
startAt() {
|
|
211
|
+
if (!isDate(dateIn.value) || !Number.isFinite(toMinutes(timeIn.value))) return '';
|
|
212
|
+
return atWallClock(dateIn.value, timeIn.value, ctx.tz).toISOString();
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Send `fn(force)`; on a clash ask, and send again with force. Resolves the answer, or null. */
|
|
218
|
+
async function withClashCheck(fn, what) {
|
|
219
|
+
try {
|
|
220
|
+
return await fn(false);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (!isClash(err)) throw err;
|
|
223
|
+
if (!await E.confirm(`${esc(errText(err))}<br><br>${esc(what)} anyway, over the clash?`)) return null;
|
|
224
|
+
return fn(true);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- New booking -------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {object} ctx
|
|
232
|
+
* @param {{serviceId?, bookableId?, date?, time?, name?, email?, phone?, notes?, contactId?}} prefill
|
|
233
|
+
*/
|
|
234
|
+
export async function newBooking(ctx, prefill = {}) {
|
|
235
|
+
const {boot} = ctx;
|
|
236
|
+
const services = boot.services.filter(s => s.active !== false || s.id === prefill.serviceId);
|
|
237
|
+
if (!boot.bookables.length || !boot.services.length) {
|
|
238
|
+
const ok = await E.confirm(!boot.bookables.length
|
|
239
|
+
? 'Add who or what can be booked first - a person or a room - then a service they provide.<br><br>Go to People & rooms now?'
|
|
240
|
+
: 'Add a service first - what can be booked, for how long, and with whom.<br><br>Go to Services now?');
|
|
241
|
+
if (ok) ctx.go(!boot.bookables.length ? 'people' : 'services', 'new');
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const fromBookable = prefill.bookableId ? services.filter(s => s.bookables.includes(prefill.bookableId)) : [];
|
|
245
|
+
const f = {
|
|
246
|
+
serviceId: M.observable(prefill.serviceId || fromBookable[0]?.id || (services.length === 1 ? services[0].id : '')),
|
|
247
|
+
bookableId: M.observable(prefill.bookableId || ''),
|
|
248
|
+
name: M.observable(prefill.name || ''),
|
|
249
|
+
email: M.observable(prefill.email || ''),
|
|
250
|
+
phone: M.observable(prefill.phone || ''),
|
|
251
|
+
notes: M.observable(prefill.notes || ''),
|
|
252
|
+
staffNote: M.observable(''),
|
|
253
|
+
notify: M.observable(boot.mailReady && boot.settings.sendConfirmation !== false),
|
|
254
|
+
saving: M.observable(false)
|
|
255
|
+
};
|
|
256
|
+
const contactId = prefill.contactId || '';
|
|
257
|
+
const providers = M.observable([]);
|
|
258
|
+
const updateProviders = () => {
|
|
259
|
+
const svc = boot.services.find(s => s.id === f.serviceId.peek());
|
|
260
|
+
const list = svc ? boot.bookables.filter(b => svc.bookables.includes(b.id)) : [];
|
|
261
|
+
providers.value = list.map(b => ({id: b.id, name: b.name + (b.active === false ? ' (off)' : '')}));
|
|
262
|
+
if (f.bookableId.peek() && !list.some(b => b.id === f.bookableId.peek())) f.bookableId.value = '';
|
|
263
|
+
};
|
|
264
|
+
updateProviders();
|
|
265
|
+
const picker = timePicker(ctx, {date: prefill.date, time: prefill.time});
|
|
266
|
+
const typed = () => [f.name.peek(), f.email.peek(), f.phone.peek(), f.notes.peek(), f.staffNote.peek(), picker.value().time].join('|');
|
|
267
|
+
const opened = typed();
|
|
268
|
+
let panel = null;
|
|
269
|
+
let made = null;
|
|
270
|
+
let sections = null;
|
|
271
|
+
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
openPanel({
|
|
274
|
+
title: 'New booking',
|
|
275
|
+
size: 'md',
|
|
276
|
+
model: {...f, providers},
|
|
277
|
+
dirty: () => !made && (typed() !== opened || Boolean(sections?.dirty())),
|
|
278
|
+
html: `<div class="bk-form">
|
|
279
|
+
${contactId ? `<p class="bk-note"><span data-icon="user" data-icon-size="13"></span> From your contact <strong>${esc(prefill.name || prefill.email)}</strong>.</p>` : ''}
|
|
280
|
+
<div class="bk-grid">
|
|
281
|
+
<div><label class="form-label">Service <span data-help-title="Service" data-help="What is being booked. Its length and buffers decide which times are free."></span></label>
|
|
282
|
+
<select class="form-select" data-part="service"><option value="">Choose…</option>${services.map(s => `<option value="${esc(s.id)}">${esc(s.name)} (${esc(minutesLabel(s.duration))})${s.active === false ? ' - off' : ''}</option>`).join('')}</select></div>
|
|
283
|
+
<div><label class="form-label">With <span data-help-title="With" data-help="The person or room. Anyone: whoever is free takes it - the least busy that day."></span></label>
|
|
284
|
+
<select class="form-select" data-part="with"></select></div>
|
|
285
|
+
</div>
|
|
286
|
+
<div data-part="picker"></div>
|
|
287
|
+
<h4 class="bk-sub">Customer</h4>
|
|
288
|
+
<div class="bk-grid">
|
|
289
|
+
<div><label class="form-label">Name</label><input class="form-input" data-model="name.value" maxlength="120"><small class="bk-err" data-err="name" hidden></small></div>
|
|
290
|
+
<div><label class="form-label">Email <span data-help-title="Email" data-help="Where the confirmation goes. With Add customers to Contacts on (settings), they are added to Contacts unless they are there already."></span></label>
|
|
291
|
+
<input class="form-input" type="email" data-model="email.value" maxlength="200"><small class="bk-err" data-err="email" hidden></small></div>
|
|
292
|
+
<div><label class="form-label">Phone</label><input class="form-input" type="tel" data-model="phone.value" maxlength="40"><small class="bk-err" data-err="phone" hidden></small></div>
|
|
293
|
+
<div class="bk-wide"><label class="form-label">Their notes <span data-help-title="Their notes" data-help="What the customer asked or told you. It is in their confirmation email."></span></label>
|
|
294
|
+
<textarea class="form-input" rows="2" data-model="notes.value" maxlength="2000"></textarea></div>
|
|
295
|
+
<div class="bk-wide"><label class="form-label">Staff note <span data-help-title="Staff note" data-help="Only for you and your team - never sent to the customer."></span></label>
|
|
296
|
+
<textarea class="form-input" rows="2" data-model="staffNote.value" maxlength="4000"></textarea></div>
|
|
297
|
+
</div>
|
|
298
|
+
<div class="bk-ext-host" data-part="ext"></div>
|
|
299
|
+
<label class="bk-check"><input type="checkbox" data-bool="notify"> Send the customer a confirmation email
|
|
300
|
+
<span data-help-title="Confirmation email" data-help="The booking's details, a calendar file, and a link to change or cancel it (within your notice rules)."></span></label>
|
|
301
|
+
${boot.mailReady ? '' : '<p class="bk-note is-warn">This site has no outgoing mail server set up, so no email can be sent.</p>'}
|
|
302
|
+
<div class="bk-actions"><span class="bk-spacer"></span>
|
|
303
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
304
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="saving.value"><span data-icon="check" data-icon-size="13"></span> Book</button></div>
|
|
305
|
+
</div>`,
|
|
306
|
+
before: (el) => {
|
|
307
|
+
el.querySelector('[data-part="picker"]').appendChild(picker.el);
|
|
308
|
+
const svcSel = el.querySelector('[data-part="service"]');
|
|
309
|
+
const withSel = el.querySelector('[data-part="with"]');
|
|
310
|
+
svcSel.value = f.serviceId.peek();
|
|
311
|
+
const drawWith = () => {
|
|
312
|
+
withSel.innerHTML = `<option value="">Anyone free</option>${providers.peek().map(p => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join('')}`;
|
|
313
|
+
withSel.value = f.bookableId.peek();
|
|
314
|
+
};
|
|
315
|
+
drawWith();
|
|
316
|
+
const serviceNow = () => boot.services.find(x => x.id === f.serviceId.peek()) || null;
|
|
317
|
+
sections = mountSections(el.querySelector('[data-part="ext"]'), 'newBooking', {boot, service: serviceNow()});
|
|
318
|
+
svcSel.addEventListener('change', () => {
|
|
319
|
+
f.serviceId.value = svcSel.value; updateProviders(); drawWith(); picker.load({service: svcSel.value, with: withSel.value});
|
|
320
|
+
sections.serviceChanged(serviceNow());
|
|
321
|
+
});
|
|
322
|
+
withSel.addEventListener('change', () => { f.bookableId.value = withSel.value; picker.load({service: svcSel.value, with: withSel.value}); });
|
|
323
|
+
const box = el.querySelector('[data-bool="notify"]');
|
|
324
|
+
box.checked = f.notify.peek();
|
|
325
|
+
box.addEventListener('change', () => { f.notify.value = box.checked; });
|
|
326
|
+
picker.load({service: f.serviceId.peek(), with: f.bookableId.peek()});
|
|
327
|
+
},
|
|
328
|
+
after: (el) => setTimeout(() => el.querySelector(f.serviceId.peek() ? '[data-part="date"]' : '[data-part="service"]')?.focus(), 60),
|
|
329
|
+
cleanup: () => { sections?.destroy(); resolve(made); },
|
|
330
|
+
methods: {
|
|
331
|
+
cancel: () => panel?.close(),
|
|
332
|
+
async save() {
|
|
333
|
+
if (f.saving.peek()) return;
|
|
334
|
+
if (!f.serviceId.peek()) { E.toast('Pick a service.', {type: 'warning'}); return; }
|
|
335
|
+
await sections?.ready;
|
|
336
|
+
const why = sections?.validate();
|
|
337
|
+
if (why) { E.toast(why, {type: 'warning'}); return; }
|
|
338
|
+
const startAt = picker.startAt();
|
|
339
|
+
if (!startAt) { E.toast('Pick a date and a time.', {type: 'warning'}); return; }
|
|
340
|
+
const body = {
|
|
341
|
+
serviceId: f.serviceId.peek(), bookableId: f.bookableId.peek(), startAt, contactId,
|
|
342
|
+
customer: {name: f.name.peek(), email: f.email.peek(), phone: f.phone.peek(), notes: f.notes.peek()},
|
|
343
|
+
staffNote: f.staffNote.peek(), notify: f.notify.peek()
|
|
344
|
+
};
|
|
345
|
+
const answers = sections?.answers() || {};
|
|
346
|
+
const ext = sections?.extFor() || {};
|
|
347
|
+
if (Object.keys(answers).length) body.answers = answers;
|
|
348
|
+
if (Object.keys(ext).length) body.ext = ext;
|
|
349
|
+
f.saving.value = true;
|
|
350
|
+
try {
|
|
351
|
+
made = await withClashCheck((force) => api.bookings.create({...body, force}), 'Book it');
|
|
352
|
+
if (!made) return;
|
|
353
|
+
E.toast(`Booked: ${made.serviceName} for ${made.name}, ${timeRange(made, ctx.tz)}.`, {type: 'success'});
|
|
354
|
+
await sections?.after(made);
|
|
355
|
+
changed(ctx);
|
|
356
|
+
panel?.close(true);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
const errors = err?.body?.errors || {};
|
|
359
|
+
const el = panel?.panel;
|
|
360
|
+
if (el) for (const box of el.querySelectorAll('[data-err]')) { box.textContent = errors[box.dataset.err] || ''; box.hidden = !errors[box.dataset.err]; }
|
|
361
|
+
E.toast(`Not booked: ${errText(err)}`, {type: 'error'});
|
|
362
|
+
} finally {
|
|
363
|
+
f.saving.value = false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}).then(p => { panel = p; });
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---- Move ------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
export function moveBooking(ctx, b) {
|
|
374
|
+
const svc = ctx.boot.services.find(s => s.id === b.serviceId);
|
|
375
|
+
const list = svc ? ctx.boot.bookables.filter(x => svc.bookables.includes(x.id)) : ctx.boot.bookables.filter(x => x.id === b.bookableId);
|
|
376
|
+
const at = wallClock(b.startAt, ctx.tz);
|
|
377
|
+
const picker = timePicker(ctx, {date: at.date, time: at.time, ignore: b.id});
|
|
378
|
+
const withId = M.observable(b.bookableId);
|
|
379
|
+
const notify = M.observable(Boolean(b.email) && ctx.boot.mailReady);
|
|
380
|
+
let panel = null;
|
|
381
|
+
let moved = null;
|
|
382
|
+
return new Promise((resolve) => {
|
|
383
|
+
openPanel({
|
|
384
|
+
title: 'Move booking',
|
|
385
|
+
size: 'md',
|
|
386
|
+
model: {withId, notify},
|
|
387
|
+
dirty: () => !moved && (picker.value().date !== at.date || picker.value().time !== at.time || withId.peek() !== b.bookableId),
|
|
388
|
+
html: `<div class="bk-form">
|
|
389
|
+
<p><strong>${esc(b.serviceName)}</strong> for <strong>${esc(b.name)}</strong><br><span class="bk-muted">Now: ${esc(timeRange(b, ctx.tz))}${withNames(b) ? ` with ${esc(withNames(b))}` : ''}</span></p>
|
|
390
|
+
<div><label class="form-label">With <span data-help-title="With" data-help="Keep the same person or room, or move it to another who provides this service."></span></label>
|
|
391
|
+
<select class="form-select" data-part="with">${list.map(x => `<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></div>
|
|
392
|
+
<div data-part="picker"></div>
|
|
393
|
+
<label class="bk-check"><input type="checkbox" data-bool="notify" ${b.email ? '' : 'disabled'}> Email the customer the new time
|
|
394
|
+
<span data-help-title="Email the customer" data-help="They get the new details and a calendar file that replaces the old one in their calendar."></span></label>
|
|
395
|
+
<div class="bk-actions"><span class="bk-spacer"></span>
|
|
396
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
397
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save"><span data-icon="move" data-icon-size="13"></span> Move</button></div>
|
|
398
|
+
</div>`,
|
|
399
|
+
before: (el) => {
|
|
400
|
+
el.querySelector('[data-part="picker"]').appendChild(picker.el);
|
|
401
|
+
const sel = el.querySelector('[data-part="with"]');
|
|
402
|
+
sel.value = b.bookableId;
|
|
403
|
+
sel.addEventListener('change', () => { withId.value = sel.value; picker.load({service: b.serviceId, with: sel.value}); });
|
|
404
|
+
const box = el.querySelector('[data-bool="notify"]');
|
|
405
|
+
box.checked = notify.peek();
|
|
406
|
+
box.addEventListener('change', () => { notify.value = box.checked; });
|
|
407
|
+
picker.load({service: b.serviceId, with: b.bookableId});
|
|
408
|
+
},
|
|
409
|
+
cleanup: () => resolve(moved),
|
|
410
|
+
methods: {
|
|
411
|
+
cancel: () => panel?.close(),
|
|
412
|
+
async save() {
|
|
413
|
+
const startAt = picker.startAt();
|
|
414
|
+
if (!startAt) { E.toast('Pick a date and a time.', {type: 'warning'}); return; }
|
|
415
|
+
try {
|
|
416
|
+
moved = await withClashCheck((force) => api.bookings.move(b.id, {startAt, bookableId: withId.peek(), notify: notify.peek(), force}), 'Move it');
|
|
417
|
+
if (!moved) return;
|
|
418
|
+
E.toast(`Moved to ${timeRange(moved, ctx.tz)}.`, {type: 'success'});
|
|
419
|
+
changed(ctx);
|
|
420
|
+
panel?.close(true);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
E.toast(`Not moved: ${errText(err)}`, {type: 'error'});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}).then(p => { panel = p; });
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---- One booking ------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
export async function openBooking(ctx, id) {
|
|
433
|
+
let b;
|
|
434
|
+
try { b = await api.bookings.get(id); } catch (err) { E.toast(`That booking could not be opened: ${errText(err)}`, {type: 'error'}); return; }
|
|
435
|
+
const staffNote = M.observable(b.staffNote || '');
|
|
436
|
+
const saved = M.observable(b.staffNote || '');
|
|
437
|
+
const tone = `is-${STATUS_TONE[b.status] || 'muted'}`;
|
|
438
|
+
const history = (b.history || []).slice().reverse().map(h => {
|
|
439
|
+
const what = {made: 'Booked', moved: 'Moved', cancelled: 'Cancelled', completed: 'Marked done', 'no-show': 'Marked no-show', confirmed: 'Back to booked', pending: 'Pending'}[h.what] || h.what;
|
|
440
|
+
const was = h.from ? wallClock(h.from, ctx.tz) : null;
|
|
441
|
+
const extra = h.what === 'moved' && was ? ` (was ${dateLabel(was.date)}, ${was.time})` : h.reason ? `: ${h.reason}` : '';
|
|
442
|
+
return `<li><strong>${esc(what)}</strong>${esc(extra)}<small>${esc(fmtDateTime(h.at))}${h.by ? ` · ${esc(h.by)}` : ''}</small></li>`;
|
|
443
|
+
}).join('');
|
|
444
|
+
const detail = (label, value, href = '') => (value ? `<dt>${esc(label)}</dt><dd>${href ? `<a href="${esc(href)}">${esc(value)}</a>` : esc(value)}</dd>` : '');
|
|
445
|
+
const live = b.status !== 'cancelled';
|
|
446
|
+
const answers = answerRows(b.ext?.answers, ctx.boot.services.find(x => x.id === b.serviceId));
|
|
447
|
+
let panel = null;
|
|
448
|
+
let sections = null;
|
|
449
|
+
const act = (fn) => async () => { const r = await fn(); if (r) panel?.close(); };
|
|
450
|
+
panel = await openPanel({
|
|
451
|
+
title: `${b.serviceName}: ${b.name}`,
|
|
452
|
+
size: 'md',
|
|
453
|
+
model: {staffNote, saved},
|
|
454
|
+
dirty: () => staffNote.peek() !== saved.peek(),
|
|
455
|
+
cleanup: () => sections?.destroy(),
|
|
456
|
+
before: (el) => {
|
|
457
|
+
sections = mountSections(el.querySelector('[data-part="ext"]'), 'bookingPanel', {boot: ctx.boot, booking: b,
|
|
458
|
+
refresh: () => { panel?.close(true); ctx.openBooking ? ctx.openBooking(b.id) : openBooking(ctx, b.id); }});
|
|
459
|
+
},
|
|
460
|
+
html: `<div class="bk-form bk-booking">
|
|
461
|
+
<div class="bk-booking-head">
|
|
462
|
+
<span class="bk-chip ${tone}">${esc(STATUS_LABEL[b.status] || b.status)}</span>
|
|
463
|
+
<strong>${esc(timeRange(b, ctx.tz))}</strong>
|
|
464
|
+
</div>
|
|
465
|
+
<dl class="bk-dl">
|
|
466
|
+
${detail('Service', `${b.serviceName} (${minutesLabel(b.duration || (Date.parse(b.endAt) - Date.parse(b.startAt)) / 60000)})`)}
|
|
467
|
+
${detail('With', withNames(b))}
|
|
468
|
+
${b.online ? detail('Where', 'Online - a video call') : ''}
|
|
469
|
+
${detail('Customer', b.name)}
|
|
470
|
+
${detail('Email', b.email, b.email ? `mailto:${b.email}` : '')}
|
|
471
|
+
${detail('Phone', b.phone, b.phone ? `tel:${b.phone.replace(/[^+\d]/g, '')}` : '')}
|
|
472
|
+
${detail('Their notes', b.notes)}
|
|
473
|
+
${detail('Booked', `${sourceLabel(b.source)} · ${fmtDateTime(b.createdAt || b.history?.[0]?.at)}`)}
|
|
474
|
+
${b.contactId && ctx.boot.contacts ? detail('Contact', 'In your Contacts', '#/contacts') : ''}
|
|
475
|
+
</dl>
|
|
476
|
+
${answers.length ? `<h4 class="bk-sub">Their answers</h4><dl class="bk-dl bk-answers">${answers.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>` : ''}
|
|
477
|
+
<div class="bk-ext-host" data-part="ext"></div>
|
|
478
|
+
${b.hostUrl && live ? `<div class="bk-actions bk-actions-start bk-call-row">
|
|
479
|
+
<a class="btn btn-sm btn-primary" href="${esc(b.hostUrl)}" target="_blank" rel="noopener"><span data-icon="video" data-icon-size="13"></span> Start the call</a>
|
|
480
|
+
<span class="bk-muted">You are the host: let ${esc(b.name || 'them')} in when they arrive. It opens 10 minutes before the start.</span></div>` : ''}
|
|
481
|
+
<div class="bk-actions bk-actions-start">
|
|
482
|
+
<button type="button" class="btn btn-sm btn-outline" data-on-click="move" ${live ? '' : 'disabled'}><span data-icon="move" data-icon-size="13"></span> Move</button>
|
|
483
|
+
<button type="button" class="btn btn-sm btn-outline" data-on-click="done" ${live && b.status !== 'completed' ? '' : 'disabled'}><span data-icon="check" data-icon-size="13"></span> Done</button>
|
|
484
|
+
<button type="button" class="btn btn-sm btn-outline" data-on-click="noshow" ${live && b.status !== 'no-show' ? '' : 'disabled'}><span data-icon="user-remove" data-icon-size="13"></span> No-show</button>
|
|
485
|
+
<span class="bk-spacer"></span>
|
|
486
|
+
${live
|
|
487
|
+
? '<button type="button" class="btn btn-sm btn-ghost bk-danger-text" data-on-click="cancelIt"><span data-icon="x-circle" data-icon-size="13"></span> Cancel booking</button>'
|
|
488
|
+
: '<button type="button" class="btn btn-sm btn-ghost bk-danger-text" data-on-click="remove"><span data-icon="trash" data-icon-size="13"></span> Delete</button>'}
|
|
489
|
+
</div>
|
|
490
|
+
<label class="form-label">Staff note <span data-help-title="Staff note" data-help="Only for you and your team - never sent to the customer. Ctrl+S saves it."></span></label>
|
|
491
|
+
<textarea class="form-input" rows="3" maxlength="4000" data-model="staffNote.value"></textarea>
|
|
492
|
+
<div class="bk-actions"><span class="bk-muted" data-bind-hidden="staffNote.value === saved.value">Not saved yet</span><span class="bk-spacer"></span>
|
|
493
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="staffNote.value === saved.value">Save note</button></div>
|
|
494
|
+
<h4 class="bk-sub">History</h4>
|
|
495
|
+
<ul class="bk-history">${history || '<li class="bk-muted">Nothing yet.</li>'}</ul>
|
|
496
|
+
</div>`,
|
|
497
|
+
methods: {
|
|
498
|
+
async save() {
|
|
499
|
+
try {
|
|
500
|
+
await api.bookings.update(b.id, {staffNote: staffNote.peek()});
|
|
501
|
+
saved.value = staffNote.peek();
|
|
502
|
+
E.toast('Note saved.', {type: 'success'});
|
|
503
|
+
changed(ctx);
|
|
504
|
+
} catch (err) {
|
|
505
|
+
E.toast(`Not saved: ${errText(err)}`, {type: 'error'});
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
move: act(() => moveBooking(ctx, b)),
|
|
509
|
+
done: act(() => setStatus(ctx, b, 'completed')),
|
|
510
|
+
noshow: act(() => setStatus(ctx, b, 'no-show')),
|
|
511
|
+
cancelIt: act(() => cancelBooking(ctx, b)),
|
|
512
|
+
remove: act(() => deleteBooking(ctx, b))
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
return panel;
|
|
516
|
+
}
|