domma-cms 0.102.1 → 0.104.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 +249 -0
- package/plugins/_lib/call/routes.js +89 -0
- package/plugins/_lib/public/call/call.css +1 -0
- package/plugins/_lib/public/call/call.js +477 -0
- package/plugins/booking/CLAUDE.md +137 -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 +207 -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 +70 -0
- package/plugins/booking/inject/body.html +1 -0
- package/plugins/booking/lib/call.js +72 -0
- package/plugins/booking/lib/extensions.js +169 -0
- package/plugins/booking/lib/mail.js +113 -0
- package/plugins/booking/lib/store.js +390 -0
- package/plugins/booking/plugin.js +806 -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 +445 -0
- package/plugins/booking/tests/public.test.js +72 -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,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking admin - what every screen shares: the admin's helpers (loaded at
|
|
3
|
+
* run time; a plugin never imports admin/ statically - the updater replaces
|
|
4
|
+
* it), the API, escaping, slideover forms, rows with a right-click / ⋮ /
|
|
5
|
+
* Shift+F10 menu, and the words and colours for statuses.
|
|
6
|
+
*
|
|
7
|
+
* @module booking/admin/lib/kit
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {fmtDate, fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
|
|
11
|
+
|
|
12
|
+
export {fmtDate, fmtDateTime};
|
|
13
|
+
|
|
14
|
+
export const SIDEBAR_URL = '#/plugins/booking';
|
|
15
|
+
export const BASE = '/api/plugins/booking';
|
|
16
|
+
export const V = '1.4.0';
|
|
17
|
+
|
|
18
|
+
let kit = null;
|
|
19
|
+
|
|
20
|
+
/** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
|
|
21
|
+
export function loadKit() {
|
|
22
|
+
if (!kit) {
|
|
23
|
+
kit = Promise.all([
|
|
24
|
+
import('/admin/js/lib/tool-kit.js').catch(() => ({})),
|
|
25
|
+
import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
|
|
26
|
+
import('/admin/js/lib/help-popover.js').catch(() => ({}))
|
|
27
|
+
]).then(([tk, chrome, help]) => ({
|
|
28
|
+
onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
|
|
29
|
+
openMenuAt: tk.openMenuAt || ((el) => {
|
|
30
|
+
const r = el.getBoundingClientRect();
|
|
31
|
+
el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
|
|
32
|
+
}),
|
|
33
|
+
viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
|
|
34
|
+
addBannerAction: chrome.addBannerAction || (() => null),
|
|
35
|
+
helpPopovers: help.helpPopovers || (() => () => {})
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
return kit;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Booking's API, through the admin's apiRequest (imported at run time): it
|
|
43
|
+
* attaches the token, refreshes it on a 401, and - unlike H - keeps the
|
|
44
|
+
* status and the whole answer on an error (`err.status`, `err.body.errors`),
|
|
45
|
+
* which a clash (409) and per-field messages need. H is the stand-in.
|
|
46
|
+
*/
|
|
47
|
+
let requester = null;
|
|
48
|
+
export function request(method, path, body) {
|
|
49
|
+
requester ??= import('/admin/js/api.js').then(m => m.apiRequest).catch(() => null);
|
|
50
|
+
return requester.then(fn => {
|
|
51
|
+
if (fn) return fn(path, {method, ...(body !== undefined ? {body: JSON.stringify(body)} : {})});
|
|
52
|
+
const url = `/api${path}`;
|
|
53
|
+
return method === 'GET' ? H.get(url) : method === 'DELETE' ? H.delete(url) : H[method.toLowerCase()](url, body);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const P = '/plugins/booking';
|
|
57
|
+
const seg = (id) => encodeURIComponent(String(id));
|
|
58
|
+
const qs = (o) => Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
|
|
59
|
+
export const api = {
|
|
60
|
+
bootstrap: () => request('GET', `${P}/bootstrap`),
|
|
61
|
+
bookings: {
|
|
62
|
+
list: (q = {}) => request('GET', `${P}/bookings?${qs(q)}`),
|
|
63
|
+
get: (id) => request('GET', `${P}/bookings/${seg(id)}`),
|
|
64
|
+
create: (d) => request('POST', `${P}/bookings`, d),
|
|
65
|
+
update: (id, d) => request('PUT', `${P}/bookings/${seg(id)}`, d),
|
|
66
|
+
move: (id, d) => request('PUT', `${P}/bookings/${seg(id)}/move`, d),
|
|
67
|
+
remove: (id) => request('DELETE', `${P}/bookings/${seg(id)}`)
|
|
68
|
+
},
|
|
69
|
+
slots: (q) => request('GET', `${P}/slots?${qs(q)}`),
|
|
70
|
+
services: {
|
|
71
|
+
list: () => request('GET', `${P}/services`),
|
|
72
|
+
create: (d) => request('POST', `${P}/services`, d),
|
|
73
|
+
update: (id, d) => request('PUT', `${P}/services/${seg(id)}`, d),
|
|
74
|
+
remove: (id) => request('DELETE', `${P}/services/${seg(id)}`)
|
|
75
|
+
},
|
|
76
|
+
bookables: {
|
|
77
|
+
list: () => request('GET', `${P}/bookables`),
|
|
78
|
+
create: (d) => request('POST', `${P}/bookables`, d),
|
|
79
|
+
update: (id, d) => request('PUT', `${P}/bookables/${seg(id)}`, d),
|
|
80
|
+
remove: (id) => request('DELETE', `${P}/bookables/${seg(id)}`)
|
|
81
|
+
},
|
|
82
|
+
settings: {
|
|
83
|
+
get: () => request('GET', `${P}/settings`),
|
|
84
|
+
save: (d) => request('PUT', `${P}/settings`, d)
|
|
85
|
+
},
|
|
86
|
+
contact: (id) => request('GET', `${P}/contact/${seg(id)}`),
|
|
87
|
+
contactByEmail: (email) => request('GET', `${P}/contact?email=${encodeURIComponent(email)}`)
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A failed request, for a person to read. H throws with the server's message
|
|
92
|
+
* but not always its status, so a clash is recognised by status OR wording.
|
|
93
|
+
*/
|
|
94
|
+
export const errText = (err) => err?.message || String(err);
|
|
95
|
+
export const isClash = (err) => err?.status === 409 && (err?.body?.code === 'slot-taken' || /clash|taken|no longer free/i.test(errText(err)));
|
|
96
|
+
|
|
97
|
+
/** E.confirm / titles render HTML: user text goes in escaped. */
|
|
98
|
+
export const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
99
|
+
|
|
100
|
+
/** A small DOM tree from an HTML string that holds NO unescaped user data. */
|
|
101
|
+
export function fragment(html) {
|
|
102
|
+
const t = document.createElement('template');
|
|
103
|
+
t.innerHTML = html.trim();
|
|
104
|
+
return t.content.firstElementChild;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const store = {
|
|
108
|
+
get(k) { try { return S.get(k); } catch { return null; } },
|
|
109
|
+
set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } }
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** The sidebar re-asks Booking's badge. */
|
|
113
|
+
export function refreshBadge() {
|
|
114
|
+
try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Words and colours ----------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export const STATUS_LABEL = {pending: 'Pending', confirmed: 'Booked', completed: 'Done', 'no-show': 'No-show', cancelled: 'Cancelled'};
|
|
120
|
+
export const STATUS_TONE = {pending: 'warning', confirmed: 'primary', completed: 'success', 'no-show': 'danger', cancelled: 'muted'};
|
|
121
|
+
export const COLOUR_LABEL = {blue: 'Blue', green: 'Green', teal: 'Teal', purple: 'Purple', pink: 'Pink', orange: 'Orange', red: 'Red', grey: 'Grey'};
|
|
122
|
+
export const KIND_LABEL = {person: 'Person', resource: 'Room or thing'};
|
|
123
|
+
|
|
124
|
+
/** "1 h 30 min", "45 min". */
|
|
125
|
+
export function minutesLabel(n) {
|
|
126
|
+
const m = Math.round(Number(n) || 0);
|
|
127
|
+
if (m < 60) return `${m} min`;
|
|
128
|
+
const h = Math.floor(m / 60);
|
|
129
|
+
return m % 60 ? `${h} h ${m % 60} min` : `${h} h`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** "Tue 15/07/2031" from a 'YYYY-MM-DD'. */
|
|
133
|
+
export function dateLabel(date) {
|
|
134
|
+
const [y, m, d] = String(date).split('-');
|
|
135
|
+
const wd = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d))).getUTCDay();
|
|
136
|
+
return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][wd]} ${d}/${m}/${y}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** "3 min ago", "26/09/2026". */
|
|
140
|
+
export function when(iso) {
|
|
141
|
+
const t = new Date(iso).getTime();
|
|
142
|
+
if (!Number.isFinite(t)) return '';
|
|
143
|
+
const s = (Date.now() - t) / 1000;
|
|
144
|
+
if (s < 60) return 'just now';
|
|
145
|
+
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
|
146
|
+
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
|
|
147
|
+
if (s < 86400 * 6) return `${Math.round(s / 86400)} d ago`;
|
|
148
|
+
return fmtDate(t);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- Slideovers ---------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Open a slideover holding `html`, bound to `model`. Closing with something
|
|
155
|
+
* changed asks first (once). Everything is disposed and removed on close.
|
|
156
|
+
*
|
|
157
|
+
* @returns {Promise<{so, panel, close: (force?: boolean) => void}>}
|
|
158
|
+
*/
|
|
159
|
+
export async function openPanel({title, size = 'md', html, model = {}, methods = {}, dirty, cleanup = () => {}, before, after}) {
|
|
160
|
+
const {onceClosed, helpPopovers} = await loadKit();
|
|
161
|
+
const so = E.slideover({title: esc(title), size, position: 'right'});
|
|
162
|
+
const panel = fragment(html);
|
|
163
|
+
so.element.appendChild(panel);
|
|
164
|
+
before?.(panel);
|
|
165
|
+
const disposeHelp = helpPopovers(panel);
|
|
166
|
+
const handle = M.applyBindings(model, panel, {methods});
|
|
167
|
+
after?.(panel);
|
|
168
|
+
onceClosed(so, () => { cleanup(); handle.dispose(); disposeHelp(); });
|
|
169
|
+
const close = so.close.bind(so);
|
|
170
|
+
let closed = false;
|
|
171
|
+
let asking = false;
|
|
172
|
+
so.close = async (force) => {
|
|
173
|
+
if (!closed && force !== true && dirty?.()) {
|
|
174
|
+
if (asking) return undefined;
|
|
175
|
+
asking = true;
|
|
176
|
+
const ok = await E.confirm('Discard your changes?');
|
|
177
|
+
asking = false;
|
|
178
|
+
if (!ok) return undefined;
|
|
179
|
+
}
|
|
180
|
+
closed = true;
|
|
181
|
+
return close();
|
|
182
|
+
};
|
|
183
|
+
panel.addEventListener('keydown', (e) => {
|
|
184
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === 'Enter' || e.key === 's') && methods.save) { e.preventDefault(); e.stopPropagation(); methods.save(); }
|
|
185
|
+
});
|
|
186
|
+
I.scan(panel);
|
|
187
|
+
so.open();
|
|
188
|
+
return {so, panel, close: (force) => so.close(force)};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Rows: click (or Enter) = the main action; right-click, ⋮ or Shift+F10 = the
|
|
193
|
+
* menu. `scope` receives the teardown.
|
|
194
|
+
*/
|
|
195
|
+
export function wireRows(list, {items, main, openMenuAt, scope, match = '.bk-row'}) {
|
|
196
|
+
const menu = E.contextMenu(list, {cursor: 'glyph', match, inherit: false, density: 'compact',
|
|
197
|
+
items: (row) => items(row.dataset.id, row)});
|
|
198
|
+
const onClick = (e) => {
|
|
199
|
+
const row = e.target.closest(match);
|
|
200
|
+
if (!row) return;
|
|
201
|
+
if (e.target.closest('[data-stop]')) return;
|
|
202
|
+
const kebab = e.target.closest('.bk-kebab');
|
|
203
|
+
if (kebab) { e.stopPropagation(); openMenuAt(row); return; }
|
|
204
|
+
main(row.dataset.id, row);
|
|
205
|
+
};
|
|
206
|
+
const onKey = (e) => {
|
|
207
|
+
const row = e.target.closest(match);
|
|
208
|
+
if (!row) return;
|
|
209
|
+
if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') { e.preventDefault(); openMenuAt(row); }
|
|
210
|
+
else if ((e.key === 'Enter' || e.key === ' ') && e.target === row) { e.preventDefault(); main(row.dataset.id, row); }
|
|
211
|
+
else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target === row) {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
const rows = [...list.querySelectorAll(match)].filter(r => r.offsetParent);
|
|
214
|
+
rows[rows.indexOf(row) + (e.key === 'ArrowDown' ? 1 : -1)]?.focus();
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
list.addEventListener('click', onClick);
|
|
218
|
+
list.addEventListener('keydown', onKey);
|
|
219
|
+
scope(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); menu?.destroy?.(); });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Copy text, and say so. */
|
|
223
|
+
export function copy(text, what = 'Copied') {
|
|
224
|
+
navigator.clipboard?.writeText(text).then(() => E.toast(`${what}.`, {type: 'success'}), () => E.toast(text));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Checkboxes are set after render and listened to (data-model on a checkbox is unreliable). */
|
|
228
|
+
export function wireChecks(el, obs) {
|
|
229
|
+
for (const box of el.querySelectorAll('[data-bool]')) {
|
|
230
|
+
const o = obs[box.dataset.bool];
|
|
231
|
+
if (!o) continue;
|
|
232
|
+
box.checked = Boolean(o.peek());
|
|
233
|
+
box.addEventListener('change', () => { o.value = box.checked; });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Show the server's per-field errors beside their inputs ([data-err="field"]). */
|
|
238
|
+
export function showErrors(el, errors = {}) {
|
|
239
|
+
for (const box of el.querySelectorAll('[data-err]')) {
|
|
240
|
+
const msg = errors[box.dataset.err] || '';
|
|
241
|
+
box.textContent = msg;
|
|
242
|
+
box.hidden = !msg;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** H's error, with the server's `errors` map when it carried one. */
|
|
247
|
+
export function errorsOf(err) {
|
|
248
|
+
return err?.body?.errors || {};
|
|
249
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div class="bk" data-booking-admin></div>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking - one Tool, one sidebar entry, its screens as tabs under the
|
|
3
|
+
* banner: Diary, Bookings, Services, People & rooms, and any an extension
|
|
4
|
+
* (Booking Pro) adds.
|
|
5
|
+
*
|
|
6
|
+
* #/plugins/booking Diary
|
|
7
|
+
* #/plugins/booking/diary
|
|
8
|
+
* #/plugins/booking/bookings[/<id>] Bookings (an id opens that booking)
|
|
9
|
+
* #/plugins/booking/services[/<id>|new]
|
|
10
|
+
* #/plugins/booking/people[/<id>|new] People & rooms
|
|
11
|
+
* #/plugins/booking/new?contact=<id> the Diary with New booking open, filled from a contact
|
|
12
|
+
* #/plugins/booking/new?email=&name= ... or from an email's sender (Email's "Book Sam…")
|
|
13
|
+
* #/plugins/booking/<ext-key> an extension's screen
|
|
14
|
+
* #/plugins/booking/<action-id>?contact=<id> | ?email=&name=
|
|
15
|
+
* the Diary, then an extension's banner action run with that prefill
|
|
16
|
+
* (Booking Pro's "Call now" from a contact's or an email's menu)
|
|
17
|
+
*
|
|
18
|
+
* Settings are behind the banner's cog, in a slideover.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {V, api, errText, loadKit, store} from '../lib/kit.js';
|
|
22
|
+
import {sectionApi} from '../lib/extension-sections.js';
|
|
23
|
+
|
|
24
|
+
const ROUTE = /^#\/plugins\/booking(\/|\?|$)/;
|
|
25
|
+
const TABS = [
|
|
26
|
+
{key: 'diary', label: 'Diary', icon: 'calendar', module: `/plugins/booking/admin/views/diary.js?v=${V}`},
|
|
27
|
+
{key: 'bookings', label: 'Bookings', icon: 'list', module: `/plugins/booking/admin/views/bookings.js?v=${V}`},
|
|
28
|
+
{key: 'services', label: 'Services', icon: 'briefcase', module: `/plugins/booking/admin/views/services.js?v=${V}`},
|
|
29
|
+
{key: 'people', label: 'People & rooms', icon: 'users', module: `/plugins/booking/admin/views/people.js?v=${V}`}
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
let lifetime = null;
|
|
33
|
+
|
|
34
|
+
/** tab, id and query from the address. `new` is the Diary with New booking open. */
|
|
35
|
+
function where() {
|
|
36
|
+
const [path, query = ''] = location.hash.split('?');
|
|
37
|
+
const m = path.match(/^#\/plugins\/booking(?:\/([a-z0-9-]+))?(?:\/([^/?#]+))?/);
|
|
38
|
+
const q = Object.fromEntries(new URLSearchParams(query));
|
|
39
|
+
const tab = m?.[1] || 'diary';
|
|
40
|
+
return {tab, id: m?.[2] ? decodeURIComponent(m[2]) : '', query: q};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const bookingView = {
|
|
44
|
+
templateUrl: '/plugins/booking/admin/templates/booking.html',
|
|
45
|
+
|
|
46
|
+
async onMount($container) {
|
|
47
|
+
const kit = await loadKit();
|
|
48
|
+
lifetime ??= kit.viewLifetime(ROUTE);
|
|
49
|
+
if (!ROUTE.test(location.hash)) return;
|
|
50
|
+
lifetime.reset();
|
|
51
|
+
const scope = (fn) => { lifetime.add(fn); return fn; };
|
|
52
|
+
const container = $container.get(0);
|
|
53
|
+
const root = container.querySelector('[data-booking-admin]') || container;
|
|
54
|
+
let mountedFor = location.hash;
|
|
55
|
+
const here = () => location.hash === mountedFor && root.isConnected;
|
|
56
|
+
const {tab, id, query} = where();
|
|
57
|
+
|
|
58
|
+
let boot;
|
|
59
|
+
try {
|
|
60
|
+
boot = await api.bootstrap();
|
|
61
|
+
} catch (err) {
|
|
62
|
+
root.innerHTML = '';
|
|
63
|
+
const p = document.createElement('p');
|
|
64
|
+
p.className = 'bk-error';
|
|
65
|
+
p.textContent = `Booking could not load: ${errText(err)}`;
|
|
66
|
+
root.appendChild(p);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!here()) return;
|
|
70
|
+
|
|
71
|
+
const tabs = [...TABS, ...(boot.tabs || [])];
|
|
72
|
+
const actions = (boot.actions || []).filter(a => a && typeof a.module === 'string' && a.module.startsWith(`/plugins/${a.owner}/`));
|
|
73
|
+
const wantNew = tab === 'new';
|
|
74
|
+
const wantAction = actions.find(a => a.id === tab) || null;
|
|
75
|
+
const current = tabs.find(t => t.key === (wantNew || wantAction ? 'diary' : tab)) || TABS[0];
|
|
76
|
+
|
|
77
|
+
// "Booking › Diary" in the banner.
|
|
78
|
+
const name = container.querySelector('.dm-plugin-banner-name');
|
|
79
|
+
if (name) {
|
|
80
|
+
name.textContent = '';
|
|
81
|
+
const a = document.createElement('span');
|
|
82
|
+
a.className = 'dm-plugin-banner-parent';
|
|
83
|
+
a.textContent = 'Booking';
|
|
84
|
+
const sep = document.createElement('span');
|
|
85
|
+
sep.className = 'dm-plugin-banner-sep';
|
|
86
|
+
sep.textContent = '›';
|
|
87
|
+
sep.setAttribute('aria-hidden', 'true');
|
|
88
|
+
name.append(a, sep, document.createTextNode(current.label));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
root.innerHTML = '';
|
|
92
|
+
const nav = document.createElement('nav');
|
|
93
|
+
nav.className = 'bk-nav';
|
|
94
|
+
nav.setAttribute('aria-label', 'Booking');
|
|
95
|
+
for (const t of tabs) {
|
|
96
|
+
const a = document.createElement('a');
|
|
97
|
+
a.href = t.key === 'diary' ? '#/plugins/booking' : `#/plugins/booking/${t.key}`;
|
|
98
|
+
const icon = document.createElement('span');
|
|
99
|
+
icon.setAttribute('data-icon', t.icon || 'plug');
|
|
100
|
+
icon.setAttribute('data-icon-size', '15');
|
|
101
|
+
a.append(icon, document.createTextNode(t.label));
|
|
102
|
+
if (t.owner) a.classList.add('is-ext');
|
|
103
|
+
if (t === current) { a.classList.add('is-on'); a.setAttribute('aria-current', 'page'); }
|
|
104
|
+
nav.appendChild(a);
|
|
105
|
+
}
|
|
106
|
+
const body = document.createElement('div');
|
|
107
|
+
body.className = 'bk-body';
|
|
108
|
+
root.append(nav, body);
|
|
109
|
+
I.scan(nav);
|
|
110
|
+
|
|
111
|
+
const ctx = {
|
|
112
|
+
kit, scope, here, root: body, container, boot, settings: boot.settings, id, query,
|
|
113
|
+
/** Tidy the address (a deep link done with) without leaving the screen. */
|
|
114
|
+
replaceHash(h) { history.replaceState(null, '', h); mountedFor = location.hash; },
|
|
115
|
+
tz: boot.settings.timeZone,
|
|
116
|
+
go(t, i = '') { location.hash = `#/plugins/booking${t && t !== 'diary' ? `/${t}` : ''}${i ? `/${encodeURIComponent(i)}` : ''}`; },
|
|
117
|
+
/** Fresh services / people / settings, after an edit elsewhere. */
|
|
118
|
+
async refresh() {
|
|
119
|
+
const next = await api.bootstrap();
|
|
120
|
+
Object.assign(boot, next);
|
|
121
|
+
ctx.settings = boot.settings;
|
|
122
|
+
ctx.tz = boot.settings.timeZone;
|
|
123
|
+
return boot;
|
|
124
|
+
},
|
|
125
|
+
async openSettings(section) {
|
|
126
|
+
const m = await import(`/plugins/booking/admin/views/settings.js?v=${V}`);
|
|
127
|
+
return m.openSettings(ctx, section);
|
|
128
|
+
},
|
|
129
|
+
async openBooking(bookingId, opts) {
|
|
130
|
+
const m = await import(`/plugins/booking/admin/views/panels.js?v=${V}`);
|
|
131
|
+
return m.openBooking(ctx, bookingId, opts);
|
|
132
|
+
},
|
|
133
|
+
async newBooking(prefill, opts) {
|
|
134
|
+
const m = await import(`/plugins/booking/admin/views/panels.js?v=${V}`);
|
|
135
|
+
return m.newBooking(ctx, prefill, opts);
|
|
136
|
+
},
|
|
137
|
+
/** Screens set this to be told a booking changed (made, moved, cancelled). */
|
|
138
|
+
onBookingsChanged: () => {}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
kit.addBannerAction($container, {id: 'booking-settings', icon: 'settings', title: 'Booking settings', onClick: () => ctx.openSettings()});
|
|
142
|
+
kit.addBannerAction($container, {id: 'booking-visit', icon: 'external-link', title: 'Open the booking page in a new tab',
|
|
143
|
+
onClick: () => window.open(ctx.settings.bookingPath, '_blank', 'noopener')});
|
|
144
|
+
kit.addBannerAction($container, {id: 'booking-new', icon: 'plus', title: 'New booking',
|
|
145
|
+
onClick: () => ctx.newBooking({})});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* An extension's banner action (Booking 1.4): its module's `run(ctx, prefill)`, imported on the click.
|
|
149
|
+
* ctx is the screen's (boot, openBooking, refresh, go...) plus what sections get (api, owner, helpPopovers).
|
|
150
|
+
* Anything that must open a new tab does so on a click of its own, never after this import.
|
|
151
|
+
*/
|
|
152
|
+
const runAction = async (a, prefill = {}) => {
|
|
153
|
+
try {
|
|
154
|
+
const mod = await import(a.module);
|
|
155
|
+
const run = mod.run || mod.default?.run;
|
|
156
|
+
if (typeof run !== 'function') throw new Error('no run()');
|
|
157
|
+
return await run({...ctx, owner: a.owner, api: sectionApi, bookables: boot.bookables, services: boot.services,
|
|
158
|
+
helpPopovers: (el) => kit.helpPopovers(el)}, prefill);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.warn(`[booking] ${a.owner} action ${a.id}:`, err);
|
|
161
|
+
E.toast(`${a.title} could not start: ${errText(err)}`, {type: 'error'});
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
for (const a of actions) {
|
|
166
|
+
kit.addBannerAction($container, {id: `booking-x-${a.id}`, icon: a.icon, title: a.title, onClick: () => runAction(a)});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const mod = await import(current.module);
|
|
171
|
+
if (!here()) return;
|
|
172
|
+
const mount = mod.mount || mod.default?.mount;
|
|
173
|
+
const cleanup = await mount(body, ctx);
|
|
174
|
+
if (typeof cleanup === 'function') scope(cleanup);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
body.innerHTML = '';
|
|
177
|
+
const p = document.createElement('p');
|
|
178
|
+
p.className = 'bk-error';
|
|
179
|
+
p.textContent = `This screen could not load: ${errText(err)}`;
|
|
180
|
+
body.appendChild(p);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
store.set('booking.last-tab', current.key);
|
|
184
|
+
|
|
185
|
+
// From Contacts or Email: the New booking slideover (or an extension's action), filled in. The
|
|
186
|
+
// address goes back to the Diary so a reload does not open it again.
|
|
187
|
+
if ((wantNew || wantAction) && here()) {
|
|
188
|
+
ctx.replaceHash('#/plugins/booking/diary');
|
|
189
|
+
const prefill = {email: query.email || '', name: query.name || ''};
|
|
190
|
+
if (query.contact) {
|
|
191
|
+
try {
|
|
192
|
+
const c = await api.contact(query.contact);
|
|
193
|
+
Object.assign(prefill, {contactId: c.id, name: c.name, email: c.email, phone: c.phone});
|
|
194
|
+
} catch (err) {
|
|
195
|
+
E.toast(`That contact could not be read: ${errText(err)}`, {type: 'warning'});
|
|
196
|
+
}
|
|
197
|
+
} else if (query.email && ctx.boot.contacts) {
|
|
198
|
+
// From an email: a sender already in the caller's Contacts books as that contact.
|
|
199
|
+
const c = await api.contactByEmail(query.email).catch(() => null);
|
|
200
|
+
if (c?.id) Object.assign(prefill, {contactId: c.id, name: c.name || prefill.name, phone: c.phone});
|
|
201
|
+
}
|
|
202
|
+
if (!ROUTE.test(location.hash)) return;
|
|
203
|
+
if (wantAction) runAction(wantAction, prefill);
|
|
204
|
+
else ctx.newBooking(prefill);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking › Bookings.
|
|
3
|
+
*
|
|
4
|
+
* Every booking as a list: chips for Upcoming, Past and Cancelled (with
|
|
5
|
+
* counts), search over name, email, phone, service and who, rows with a
|
|
6
|
+
* right-click / ⋮ / Shift+F10 menu (the same one the Diary has). Click opens
|
|
7
|
+
* the booking. A notification's link (#/plugins/booking/bookings/<id>) opens one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {STATUS_LABEL, STATUS_TONE, V, api, dateLabel, errText, esc, store, wireRows} from '../lib/kit.js';
|
|
11
|
+
import {wallClock} from '/plugins/booking/public/lib/time.js';
|
|
12
|
+
import {sourceShort, withNames} from '../lib/details.js';
|
|
13
|
+
|
|
14
|
+
const FILTERS = [
|
|
15
|
+
{key: 'upcoming', label: 'Upcoming', match: (b, now) => b.status !== 'cancelled' && b.endAt > now},
|
|
16
|
+
{key: 'today', label: 'Today', match: (b, now, today, tz) => b.status !== 'cancelled' && wallClock(b.startAt, tz).date === today},
|
|
17
|
+
{key: 'past', label: 'Past', match: (b, now) => b.status !== 'cancelled' && b.endAt <= now},
|
|
18
|
+
{key: 'cancelled', label: 'Cancelled', match: (b) => b.status === 'cancelled'},
|
|
19
|
+
{key: 'all', label: 'All', match: () => true}
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const ROW = `
|
|
23
|
+
<div class="bk-row" data-id="{{id}}" tabindex="0">
|
|
24
|
+
<span class="bk-dot" data-bind-data-colour="colour"></span>
|
|
25
|
+
<span class="bk-when-col"><strong data-bind-text="day"></strong><small data-bind-text="time"></small></span>
|
|
26
|
+
<span class="bk-main"><strong data-bind-text="who"></strong><small data-bind-text="meta"></small></span>
|
|
27
|
+
<span class="bk-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
|
|
28
|
+
<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
29
|
+
</div>`;
|
|
30
|
+
|
|
31
|
+
export async function mount(root, ctx) {
|
|
32
|
+
const {kit, scope, here, boot, tz} = ctx;
|
|
33
|
+
const panels = await import(`/plugins/booking/admin/views/panels.js?v=${V}`);
|
|
34
|
+
if (!here()) return;
|
|
35
|
+
|
|
36
|
+
const all = M.observable([]);
|
|
37
|
+
const loading = M.observable(true);
|
|
38
|
+
const error = M.observable('');
|
|
39
|
+
const filter = M.observable(FILTERS.some(f => f.key === store.get('booking.list-filter')) ? store.get('booking.list-filter') : 'upcoming');
|
|
40
|
+
const query = M.observable('');
|
|
41
|
+
scope(M.effect(() => store.set('booking.list-filter', filter.value)));
|
|
42
|
+
const colours = new Map(boot.bookables.map(b => [b.id, b.colour || 'blue']));
|
|
43
|
+
|
|
44
|
+
const counts = M.observable({});
|
|
45
|
+
const rows = M.observable([]);
|
|
46
|
+
scope(M.effect(() => {
|
|
47
|
+
const now = new Date().toISOString();
|
|
48
|
+
const c = {};
|
|
49
|
+
for (const f of FILTERS) c[f.key] = all.value.filter(b => f.match(b, now, boot.today, tz)).length;
|
|
50
|
+
counts.value = c;
|
|
51
|
+
const f = FILTERS.find(x => x.key === filter.value) || FILTERS[0];
|
|
52
|
+
const q = query.value.trim().toLowerCase();
|
|
53
|
+
const list = all.value
|
|
54
|
+
.filter(b => f.match(b, now, boot.today, tz))
|
|
55
|
+
.filter(b => !q || [b.name, b.email, b.phone, b.serviceName, withNames(b), b.notes].join(' ').toLowerCase().includes(q));
|
|
56
|
+
// Coming up: soonest first. Looking back: latest first.
|
|
57
|
+
list.sort((a, b) => (f.key === 'upcoming' || f.key === 'today' ? a.startAt.localeCompare(b.startAt) : b.startAt.localeCompare(a.startAt)));
|
|
58
|
+
rows.value = list.map(b => {
|
|
59
|
+
const s = wallClock(b.startAt, tz);
|
|
60
|
+
const e = wallClock(b.endAt, tz);
|
|
61
|
+
return {
|
|
62
|
+
id: b.id,
|
|
63
|
+
colour: colours.get(b.bookableId) || 'grey',
|
|
64
|
+
day: dateLabel(s.date),
|
|
65
|
+
time: `${s.time}-${e.time}`,
|
|
66
|
+
who: b.name || b.email,
|
|
67
|
+
meta: [b.serviceName, withNames(b) && `with ${withNames(b)}`, b.email, sourceShort(b.source)].filter(Boolean).join(' · '),
|
|
68
|
+
statusLabel: STATUS_LABEL[b.status] || b.status,
|
|
69
|
+
tone: `is-${STATUS_TONE[b.status] || 'muted'}`
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}));
|
|
73
|
+
const emptyText = M.computed(() => (all.value.length
|
|
74
|
+
? (query.value ? 'No bookings match.' : 'Nothing here.')
|
|
75
|
+
: 'No bookings yet. They appear here as soon as someone books - on your booking page, or from New booking.'));
|
|
76
|
+
|
|
77
|
+
root.innerHTML = '';
|
|
78
|
+
const view = document.createElement('div');
|
|
79
|
+
view.className = 'bk-card bk-bookings';
|
|
80
|
+
view.innerHTML = `
|
|
81
|
+
<div class="bk-bar">
|
|
82
|
+
<div class="bk-seg" role="group" aria-label="Show">
|
|
83
|
+
${FILTERS.map(f => `<button type="button" data-filter="${f.key}" data-bind-class="filter.value === '${f.key}' && 'is-on'">${esc(f.label)} <b data-bind-text="counts.value.${f.key}"></b></button>`).join('')}
|
|
84
|
+
</div>
|
|
85
|
+
<input type="search" class="form-input bk-search" placeholder="Search name, email, service…" aria-label="Search bookings" data-model="query.value">
|
|
86
|
+
<span data-help-title="Bookings" data-help="Every booking, newest changes included. Click one to open it; right-click (or ⋮, or Shift+F10) to move it, mark it done or a no-show, or cancel it. Cancelled bookings free their time and can then be deleted."></span>
|
|
87
|
+
</div>
|
|
88
|
+
<p class="bk-note" data-bind-hidden="!loading.value">Loading…</p>
|
|
89
|
+
<p class="bk-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
|
|
90
|
+
<p class="bk-note" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
|
|
91
|
+
<div class="bk-list" data-each="rows.value key=id"></div>`;
|
|
92
|
+
root.appendChild(view);
|
|
93
|
+
view.querySelector('.bk-list').innerHTML = ROW;
|
|
94
|
+
scope(kit.helpPopovers(view, {position: 'bottom'}));
|
|
95
|
+
const bindings = M.applyBindings({loading, error, filter, query, rows, counts, emptyText}, view);
|
|
96
|
+
scope(() => bindings.dispose());
|
|
97
|
+
view.querySelector('.bk-seg').addEventListener('click', (e) => {
|
|
98
|
+
const b = e.target.closest('[data-filter]');
|
|
99
|
+
if (b) filter.value = b.dataset.filter;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
async function load() {
|
|
103
|
+
try {
|
|
104
|
+
const list = await api.bookings.list();
|
|
105
|
+
if (!here()) return;
|
|
106
|
+
all.value = Array.isArray(list) ? list : [];
|
|
107
|
+
error.value = '';
|
|
108
|
+
} catch (err) {
|
|
109
|
+
error.value = `The bookings could not load: ${errText(err)}`;
|
|
110
|
+
} finally {
|
|
111
|
+
loading.value = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
ctx.onBookingsChanged = () => { if (here()) load(); };
|
|
115
|
+
scope(() => { ctx.onBookingsChanged = () => {}; });
|
|
116
|
+
|
|
117
|
+
const byId = (id) => all.peek().find(b => b.id === id);
|
|
118
|
+
wireRows(view.querySelector('.bk-list'), {
|
|
119
|
+
openMenuAt: kit.openMenuAt,
|
|
120
|
+
scope,
|
|
121
|
+
main: (id) => ctx.openBooking(id),
|
|
122
|
+
items: (id) => panels.bookingMenu(ctx, byId(id))
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await load();
|
|
126
|
+
if (ctx.id && here()) ctx.openBooking(ctx.id);
|
|
127
|
+
}
|