@acms-subir/core 0.3.3 → 0.3.4

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/cli/preview.mjs +8 -4
  3. package/cli/sections.mjs +4 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/theme-authoring.md +19 -3
  6. package/integration.mjs +21 -4
  7. package/package.json +14 -12
  8. package/src/actions/index.ts +186 -3
  9. package/src/components/booking/BookingFlow.astro +6 -3
  10. package/src/components/calendar/BookingsHead.astro +51 -0
  11. package/src/components/calendar/CalendarBoard.tsx +618 -0
  12. package/src/components/calendar/MiniMonth.tsx +54 -0
  13. package/src/components/calendar/NewEventDialog.tsx +44 -0
  14. package/src/components/calendar/QuickAdd.tsx +263 -0
  15. package/src/components/calendar/RoomHolds.tsx +203 -0
  16. package/src/components/calendar/WhereItLands.tsx +51 -0
  17. package/src/components/editor/EventPanel.tsx +23 -2
  18. package/src/components/editor/PostEditor.tsx +32 -4
  19. package/src/layouts/Admin.astro +5 -6
  20. package/src/layouts/Site.astro +29 -2
  21. package/src/lib/backup/content.ts +6 -1
  22. package/src/lib/booking/agent.ts +3 -2
  23. package/src/lib/booking/approval.ts +70 -0
  24. package/src/lib/booking/holds.ts +120 -0
  25. package/src/lib/booking/service.ts +37 -3
  26. package/src/lib/calendar/agent-schema.ts +9 -1
  27. package/src/lib/calendar/agent.ts +16 -1
  28. package/src/lib/calendar/board.ts +171 -0
  29. package/src/lib/calendar/config.ts +3 -0
  30. package/src/lib/calendar/grid.ts +169 -0
  31. package/src/lib/calendar/migration.ts +16 -0
  32. package/src/lib/calendar/render.ts +9 -7
  33. package/src/lib/calendar/schedule.ts +166 -0
  34. package/src/lib/content-service.ts +5 -0
  35. package/src/lib/drafts.ts +1 -1
  36. package/src/lib/mobile/calendar.ts +10 -6
  37. package/src/lib/mobile/http.ts +6 -0
  38. package/src/lib/repo/migrations.ts +3 -0
  39. package/src/lib/repo/sql.ts +28 -16
  40. package/src/lib/repo/types.ts +2 -2
  41. package/src/lib/scheduler.ts +3 -1
  42. package/src/lib/schema.ts +49 -0
  43. package/src/lib/sections.ts +33 -8
  44. package/src/lib/site-chrome.ts +34 -0
  45. package/src/lib/theme-assets.ts +16 -0
  46. package/src/lib/theme.ts +17 -3
  47. package/src/middleware.ts +6 -2
  48. package/src/pages/admin/calendar/bookings.astro +32 -26
  49. package/src/pages/admin/calendar/calendars.astro +4 -263
  50. package/src/pages/admin/calendar/events/index.astro +41 -83
  51. package/src/pages/admin/calendar/index.astro +43 -0
  52. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  53. package/src/pages/admin/calendar/resources/index.astro +16 -17
  54. package/src/pages/admin/calendar/resources/link.astro +52 -12
  55. package/src/pages/admin/calendar/settings.astro +458 -0
  56. package/src/pages/admin/integrations.astro +5 -11
  57. package/src/pages/book/[resource].ics.ts +7 -1
  58. package/src/styles/admin.css +48 -0
  59. package/src/styles/cards.css +5 -1
  60. package/src/styles/editor.css +36 -0
  61. package/src/styles/schedule.css +325 -0
  62. package/src/styles/sections.css +19 -16
  63. package/src/theme-sdk/fields.mjs +5 -1
  64. package/src/theme-sdk/index.ts +5 -1
  65. package/src/virtual.d.ts +3 -0
  66. package/src/pages/admin/calendar/index.ts +0 -4
@@ -1,6 +1,9 @@
1
1
  ---
2
2
  import Admin from '../../../../layouts/Admin.astro';
3
3
  import Modal from '../../../../components/Modal.astro';
4
+ import NewEventDialog from '../../../../components/calendar/NewEventDialog';
5
+ import { bookingsEnabled } from '../../../../lib/calendar/config';
6
+ import { todayIn } from '../../../../lib/calendar/board';
4
7
  import ContentList, { type ContentItem } from '../../../../components/ContentList.astro';
5
8
  import { getRepo } from '../../../../lib/repo';
6
9
  import { slugify, type Status } from '../../../../lib/schema';
@@ -16,9 +19,10 @@ import { siteLocale } from '../../../../lib/dates';
16
19
  import { waitUntilFrom } from '../../../../lib/webhooks';
17
20
  import { invalidateRedirectCache } from '../../../../middleware';
18
21
 
19
- // Events list: upcoming first, grouped by the month each event next happens.
20
- // Feed calendars (live ICS mirrors) appear as read-only rows with a "Copy"
21
- // action that turns one into an editable draft.
22
+ // Schedule → List: upcoming first, grouped by the month each event next
23
+ // happens. Subscribed calendars appear as read-only rows with a "Make an
24
+ // editable copy" action. New events open the quick-add dialog (with its mini
25
+ // month); subscribing lives in Calendar settings.
22
26
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
23
27
 
24
28
  const repo = await getRepo(Astro.locals);
@@ -107,23 +111,6 @@ if (Astro.request.method === 'POST') {
107
111
  }
108
112
  }
109
113
 
110
- // live mirror of an external calendar
111
- if (op === 'subscribe') {
112
- const url = normalizeCalendarSrc(String(form.get('url') || ''));
113
- const name = String(form.get('name') || '').trim() || 'Imported calendar';
114
- if (!url) return back('Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
115
- try {
116
- await loadFeed(url, now);
117
- } catch (err) {
118
- return back(err instanceof Error ? err.message : 'Couldn’t load that feed.');
119
- }
120
- const base = slugify(name);
121
- let slug = base;
122
- for (let n = 2; await repo.getCalendarBySlug(slug); n++) slug = `${base}-${n}`;
123
- await repo.createCalendar({ name, slug, kind: 'feed', feedUrl: url, color: safeColor(String(form.get('color') || '#2f9e6e')) });
124
- return back(`Subscribed to “${name}” — its events now show on your calendar.`);
125
- }
126
-
127
114
  // one-time import: .ics / .csv file, or an ICS URL, into drafts
128
115
  if (op === 'import') {
129
116
  const calendarId = String(form.get('calendarId') || '');
@@ -221,16 +208,16 @@ if ((scope === 'upcoming' || scope === 'all') && feedCals.length) {
221
208
  const uid = uids.get(`${occ.title}|${occ.location}`) ?? '';
222
209
  items.push({
223
210
  id: `feed-${c.id}-${occ.start}`,
224
- href: `/admin/calendar/calendars#cal-${c.id}`,
211
+ href: `/admin/calendar/settings#cal-${c.id}`,
225
212
  title: occ.title,
226
213
  sub: '',
227
214
  meta: [occ.location, `from ${c.name}`].filter(Boolean).join(' · '),
228
215
  when: occ.allDay ? f.range : f.range,
229
216
  at: occ.start,
230
217
  status: 'feed',
231
- statusLabel: 'Read-only · feed',
218
+ statusLabel: 'Subscribed · read-only',
232
219
  readonly: true,
233
- action: uid ? { label: 'Copy', fields: { op: 'copy', calendarId: c.id, uid } } : undefined,
220
+ action: uid ? { label: 'Make editable copy', fields: { op: 'copy', calendarId: c.id, uid } } : undefined,
234
221
  });
235
222
  }
236
223
  } catch (err) {
@@ -261,72 +248,45 @@ const tomorrowIso = `${tomorrow.y}-${String(tomorrow.m).padStart(2, '0')}-${Stri
261
248
  const subBits = [
262
249
  `${counts.upcoming} upcoming`,
263
250
  counts.drafts ? `${counts.drafts} draft${counts.drafts === 1 ? '' : 's'}` : '',
264
- feedCals.length ? `${feedCals.length} feed calendar${feedCals.length === 1 ? '' : 's'}` : '',
251
+ feedCals.length ? `${feedCals.length} subscribed calendar${feedCals.length === 1 ? '' : 's'}` : '',
265
252
  ].filter(Boolean);
266
253
  ---
267
254
 
268
- <Admin title="Events" section="calendar:events">
255
+ <Admin title="Schedule" section="calendar:schedule">
269
256
  <div class="page-head">
270
257
  <div>
271
- <h1>Events</h1>
258
+ <h1>All events</h1>
272
259
  <p class="sub">{subBits.join(' · ')} · public at <a href={CALENDAR.base} target="_blank" rel="noopener">{CALENDAR.base}</a></p>
273
260
  </div>
274
261
  <div class="page-actions">
275
- <button class="btn" type="button" data-modal="import-events">Import</button>
276
- <button class="btn primary" type="button" data-modal="new-event">New event</button>
262
+ <nav class="seg" aria-label="View">
263
+ <a href="/admin/calendar">Month</a>
264
+ <a href="/admin/calendar?view=week">Week</a>
265
+ <a class="on" href="/admin/calendar/events" aria-current="page">List</a>
266
+ </nav>
267
+ <a class="btn ico" href="/admin/calendar/settings" title="Calendar settings" aria-label="Calendar settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg></a>
268
+ <details class="newmenu">
269
+ <summary class="btn primary">New <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={chev} /></svg></summary>
270
+ <div class="newmenu-pop" role="menu">
271
+ <button type="button" role="menuitem" data-new-event>Event <kbd>N</kbd></button>
272
+ {bookingsEnabled() && <a role="menuitem" href="/admin/calendar/bookings?block=1">Block time <small>room closed, time off</small></a>}
273
+ <button type="button" role="menuitem" data-modal="import-events">Import events… <small>.ics or .csv</small></button>
274
+ </div>
275
+ </details>
277
276
  </div>
278
277
  </div>
279
278
 
280
279
  {notice && <p class="notice" role="status">{notice}</p>}
281
280
  {feedErrors.map((e) => <p class="notice warn">Couldn’t refresh {e}</p>)}
282
281
 
283
- <Modal id="new-event" title="New event" open={Astro.url.searchParams.has('new')}>
284
- <form method="post">
285
- <input type="hidden" name="op" value="create" />
286
- <label class="field">
287
- <span>Title</span>
288
- <input type="text" name="title" placeholder="Fall Festival" required autofocus />
289
- </label>
290
- <div class="field-row">
291
- <label class="field"><span>Date</span><input type="date" name="date" value={tomorrowIso} required /></label>
292
- <label class="field"><span>Time <em class="hint">blank = all day</em></span><input type="time" name="time" value="10:00" step="300" /></label>
293
- </div>
294
- {nativeCals.length > 0 && (
295
- <label class="field">
296
- <span>Calendar</span>
297
- <select name="calendarId">
298
- <option value="">None — main calendar only</option>
299
- {nativeCals.map((c) => <option value={c.id} selected={c.id === calFilter}>{c.name}</option>)}
300
- </select>
301
- </label>
302
- )}
303
- <div class="modal-foot">
304
- <button class="btn ghost" type="button" data-close>Cancel</button>
305
- <button class="btn primary" type="submit">Create event</button>
306
- </div>
307
- <p class="hint modal-note">Creates a draft and opens the editor, where you add the image, location, button and repeat rule.</p>
308
- </form>
309
- </Modal>
282
+ <NewEventDialog client:load calendars={nativeCals.map((c) => ({ id: c.id, name: c.name, color: safeColor(c.color) }))}
283
+ defaultCalendarId={settings.calendar.defaultCalendarId || calFilter} weekStart={settings.calendar.weekStart} locale={locale}
284
+ today={todayIn(tz)} day={tomorrowIso} open={Astro.url.searchParams.has('new')} />
310
285
 
311
- <Modal id="import-events" title="Bring in events">
312
- <div class="seg block ev-import-tabs" role="tablist">
313
- <button type="button" role="tab" aria-selected="true" data-tab="subscribe">Subscribe to a calendar</button>
314
- <button type="button" role="tab" aria-selected="false" data-tab="once">Import once</button>
315
- </div>
316
- <form method="post" data-panel="subscribe">
317
- <input type="hidden" name="op" value="subscribe" />
318
- <p class="hint">Mirror a Google, Apple or Outlook calendar live. Its events show on your calendar and stay in sync, read-only.</p>
319
- <label class="field"><span>Name</span><input type="text" name="name" placeholder="Staff calendar" required /></label>
320
- <label class="field"><span>Calendar link</span><input class="mono" type="text" name="url" placeholder="Google Calendar link, calendar ID, or an .ics URL" required /></label>
321
- <label class="field"><span>Color</span><input type="color" name="color" value="#2f9e6e" /></label>
322
- <div class="modal-foot">
323
- <button class="btn ghost" type="button" data-close>Cancel</button>
324
- <button class="btn primary" type="submit">Subscribe</button>
325
- </div>
326
- </form>
327
- <form method="post" enctype="multipart/form-data" data-panel="once" hidden>
286
+ <Modal id="import-events" title="Import events" open={Astro.url.searchParams.has('import')}>
287
+ <form method="post" enctype="multipart/form-data">
328
288
  <input type="hidden" name="op" value="import" />
329
- <p class="hint">Copy events in as editable drafts. Works with .ics exports from Google, Apple and Outlook, or a spreadsheet saved as .csv with Title and Start date columns.</p>
289
+ <p class="hint">Copy events in once, as editable drafts. Works with .ics exports from Google, Apple and Outlook, or a spreadsheet saved as .csv with Title and Start date columns. To keep a calendar in sync instead, <a href="/admin/calendar/settings?subscribe=1#subscribed">subscribe to it</a>.</p>
330
290
  <label class="field"><span>File</span><input type="file" name="file" accept=".ics,.csv,text/calendar,text/csv" /></label>
331
291
  <label class="field"><span>Or a calendar link</span><input class="mono" type="text" name="url" placeholder="https://calendar.google.com/…" /></label>
332
292
  {nativeCals.length > 0 && (
@@ -348,10 +308,9 @@ const subBits = [
348
308
  {events.length === 0 && feedCals.length === 0 ? (
349
309
  <div class="empty-state">
350
310
  <b>No events yet</b>
351
- <p>Add your first event, or bring in an existing Google, Apple or Outlook calendar.</p>
311
+ <p>Events are things that happen on a date. Add one, or import a calendar file from Google, Apple or Outlook.</p>
352
312
  <div class="empty-actions">
353
- <button class="btn" type="button" data-modal="import-events">Import</button>
354
- <button class="btn primary" type="button" data-modal="new-event">New event</button>
313
+ <button class="btn primary" type="button" data-new-event>New event</button>
355
314
  </div>
356
315
  </div>
357
316
  ) : (
@@ -386,7 +345,7 @@ const subBits = [
386
345
  <ContentList items={items} noun="event" timeZone={settings.timezone || undefined} groupByMonth={true} empty="No events match the current filters." />
387
346
  {items.length === 0 && (
388
347
  <p class="hint" style="padding:24px 8px">
389
- {scope === 'upcoming' ? 'Nothing coming up.' : 'No events match the current filters.'} <a href="/admin/calendar/events?new=1">Add an event</a>
348
+ {scope === 'upcoming' ? 'Nothing coming up.' : 'No events match the current filters.'} <button class="linklike" type="button" data-new-event>Add an event</button>
390
349
  </p>
391
350
  )}
392
351
  </>
@@ -399,10 +358,9 @@ const subBits = [
399
358
  if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
400
359
  });
401
360
  });
402
- // import modal tabs
403
- const tabs = document.querySelectorAll<HTMLButtonElement>('.ev-import-tabs [data-tab]');
404
- tabs.forEach((t) => t.addEventListener('click', () => {
405
- tabs.forEach((x) => x.setAttribute('aria-selected', String(x === t)));
406
- document.querySelectorAll<HTMLElement>('#import-events [data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== t.dataset.tab; });
407
- }));
361
+ // the New ▾ menu closes like the filter menus
362
+ document.addEventListener('click', (e) => {
363
+ const m = document.querySelector<HTMLDetailsElement>('details.newmenu[open]');
364
+ if (m && (!(e.target instanceof Node) || !m.contains(e.target) || (e.target as Element).closest('[role=menuitem]'))) m.open = false;
365
+ });
408
366
  </script>
@@ -0,0 +1,43 @@
1
+ ---
2
+ import Admin from '../../../layouts/Admin.astro';
3
+ import CalendarBoard from '../../../components/calendar/CalendarBoard';
4
+ import { getRepo } from '../../../lib/repo';
5
+ import { CALENDAR, bookingsEnabled } from '../../../lib/calendar/config';
6
+ import { loadBoard, todayIn } from '../../../lib/calendar/board';
7
+ import { isIsoDay, isIsoMonth, monthWeeks, weekDays } from '../../../lib/calendar/grid';
8
+ import { siteLocale } from '../../../lib/dates';
9
+
10
+ // Schedule: the Calendar section's home — a month (or week) grid of every
11
+ // event, draft, subscribed calendar and booking. ?m=YYYY-MM picks the month,
12
+ // ?view=week&d=YYYY-MM-DD a week; the list lives at /admin/calendar/events.
13
+ if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
14
+ if (Astro.url.searchParams.get('view') === 'list') return Astro.redirect('/admin/calendar/events');
15
+
16
+ const repo = await getRepo(Astro.locals);
17
+ const settings = await repo.getSettings();
18
+ const tz = settings.timezone || 'UTC';
19
+ const today = todayIn(tz);
20
+ const weekStart = settings.calendar.weekStart;
21
+ const view = Astro.url.searchParams.get('view') === 'week' ? 'week' : 'month';
22
+ const d = Astro.url.searchParams.get('d') ?? '';
23
+ const m = Astro.url.searchParams.get('m') ?? '';
24
+ const anchor = isIsoDay(d) ? d : isIsoMonth(m) ? `${m}-01` : today;
25
+ const weeks = monthWeeks(anchor.slice(0, 7), weekStart);
26
+ const [from, to] = view === 'week' ? [weekDays(anchor, weekStart)[0], weekDays(anchor, weekStart)[6]] : [weeks[0][0], weeks[weeks.length - 1][6]];
27
+ const initial = await loadBoard(repo, settings, from, to);
28
+ ---
29
+
30
+ <Admin title="Schedule" section="calendar:schedule" wide>
31
+ <CalendarBoard
32
+ client:load
33
+ initial={initial}
34
+ view={view}
35
+ anchor={anchor}
36
+ today={today}
37
+ weekStart={weekStart}
38
+ locale={siteLocale(settings)}
39
+ defaultCalendarId={settings.calendar.defaultCalendarId}
40
+ bookings={bookingsEnabled()}
41
+ listHref="/admin/calendar/events"
42
+ />
43
+ </Admin>
@@ -9,6 +9,9 @@ import { describeLink } from '../../../../lib/booking/availability';
9
9
  import { isValidTimeZone } from '../../../../lib/calendar/events';
10
10
  import { normalizeCalendarSrc } from '../../../../lib/calendar/src';
11
11
  import { siteOrigin } from '../../../../lib/seo';
12
+ import { approvalShort, linkApproval } from '../../../../lib/booking/approval';
13
+ import { eventHolds } from '../../../../lib/booking/holds';
14
+ import { fmt } from '../../../../lib/booking/view';
12
15
 
13
16
  // One resource: details, weekly hours, date overrides, calendars that block
14
17
  // time, who gets notified, the staff feed, and its booking links.
@@ -53,7 +56,7 @@ if (Astro.request.method === 'POST') {
53
56
  const base = slugify(name);
54
57
  let slug = base;
55
58
  for (let n = 2; await repo.getBookingLinkBySlug(id, slug); n++) slug = `${base}-${n}`;
56
- const l = await repo.createBookingLink({ resourceId: id, name, slug, autoConfirm: true });
59
+ const l = await repo.createBookingLink({ resourceId: id, name, slug });
57
60
  return Astro.redirect(`/admin/calendar/resources/${id}/links/${l.id}`);
58
61
  } else if (op === 'rotate') {
59
62
  await repo.updateResource(id, { feedToken: token() });
@@ -65,14 +68,17 @@ if (Astro.request.method === 'POST') {
65
68
  resource = (await repo.getResource(id))!;
66
69
  }
67
70
  const links = await repo.listBookingLinks(id);
71
+ // events holding this room in the next 90 days (lib/booking/holds.ts)
72
+ const holds = (await eventHolds(repo, settings, Date.now(), Date.now() + 90 * 86_400_000, id)).slice(0, 12);
73
+ const holdFmt = fmt(settings, resource.timezone || settings.timezone || 'UTC');
68
74
  const staffFeed = origin + resourceFeedUrl(resource.slug, resource.feedToken);
69
75
  const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], label: '' }, { date: '', endDate: '', ranges: [], label: '' }];
70
76
  ---
71
77
 
72
- <Admin title={resource.name} section="calendar:resources">
78
+ <Admin title={resource.name} section="calendar:bookings">
73
79
  <div class="page-head">
74
80
  <div>
75
- <p class="sub"><a href="/admin/calendar/resources">Resources</a> ›</p>
81
+ <p class="sub"><a href="/admin/calendar/bookings">Bookings</a> › <a href="/admin/calendar/resources">Rooms &amp; people</a> ›</p>
76
82
  <h1>{resource.name}</h1>
77
83
  <p class="sub"><a href={resourceUrl(resource.slug)} target="_blank" rel="noopener">{origin.replace(/^https?:\/\//, '')}{resourceUrl(resource.slug)} ↗</a></p>
78
84
  </div>
@@ -89,7 +95,7 @@ const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], labe
89
95
  {links.map((l) => (
90
96
  <a class="res-linkrow" href={`/admin/calendar/resources/${id}/links/${l.id}`}>
91
97
  <span><b>{l.name}</b>{!l.active && <span class="pill lang">Off</span>}{l.visibility === 'unlisted' && <span class="pill lang">Unlisted</span>}</span>
92
- <small>{describeLink(l)} · {l.mode === 'range' ? 'choose a length' : l.mode === 'day' ? 'whole days' : 'fixed length'} · <em class={l.autoConfirm ? 'auto' : 'manual'}>{l.autoConfirm ? 'Confirms automatically' : 'Needs approval'}</em></small>
98
+ <small>{describeLink(l)} · {l.mode === 'range' ? 'choose a length' : l.mode === 'day' ? 'whole days' : 'fixed length'} · {(() => { const a = linkApproval(l, settings); return <em class={a.mode === 'auto' ? 'auto' : 'manual'}>{approvalShort(a)}{a.inherited ? ' (site setting)' : ''}</em>; })()}</small>
93
99
  <code>{bookingLinkUrl(resource.slug, l.slug)}</code>
94
100
  </a>
95
101
  ))}
@@ -97,10 +103,26 @@ const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], labe
97
103
  <form method="post" class="res-newlink">
98
104
  <input type="hidden" name="op" value="link" />
99
105
  <input type="text" name="linkName" placeholder="New link, e.g. “Event or reception”" required />
100
- <button class="btn" type="submit">Add link</button>
106
+ <button class="btn" type="submit">Add booking link</button>
101
107
  </form>
102
108
  </section>
103
109
 
110
+ <section class="card card-pad">
111
+ <div class="card-head"><b>Held by events</b><span class="hint">Events that use this {resource.kind.toLowerCase()} can’t be booked over</span></div>
112
+ {holds.length === 0 ? (
113
+ <p class="hint" style="margin:0">No events are holding it in the next 90 days. Tick it under <b>Rooms</b> in an event’s settings to hold it.</p>
114
+ ) : (
115
+ <div class="res-linklist">
116
+ {holds.map((h) => (
117
+ <a class="res-linkrow" href={`/admin/calendar/events/${h.eventId}`}>
118
+ <span><b>{h.title}</b></span>
119
+ <small>{holdFmt.day.format(h.start)} · {h.end - h.start >= 86_400_000 - 3_600_000 ? 'all day' : `${holdFmt.time.format(h.start)} – ${holdFmt.time.format(h.end)}`}</small>
120
+ </a>
121
+ ))}
122
+ </div>
123
+ )}
124
+ </section>
125
+
104
126
  <section class="card card-pad">
105
127
  <div class="card-head"><b>Open hours</b><span class="hint">The outer limit for every link</span></div>
106
128
  <HoursEditor hours={resource.hours} prefix="hours" form="res-form" note={`Times in ${resource.timezone || settings.timezone || 'UTC'}. Links can narrow these hours but never extend them.`} />
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  import Admin from '../../../../layouts/Admin.astro';
3
3
  import Modal from '../../../../components/Modal.astro';
4
+ import BookingsHead from '../../../../components/calendar/BookingsHead.astro';
5
+ import { approvalShort, linkApproval } from '../../../../lib/booking/approval';
4
6
  import { getRepo } from '../../../../lib/repo';
5
7
  import { slugify } from '../../../../lib/schema';
6
8
  import { bookingsEnabled, CALENDAR, resourceUrl } from '../../../../lib/calendar/config';
@@ -9,8 +11,9 @@ import { availabilityContext } from '../../../../lib/booking/service';
9
11
  import { describeLink, nextOpenings } from '../../../../lib/booking/availability';
10
12
  import { fmt } from '../../../../lib/booking/view';
11
13
 
12
- // Everything people can book: rooms, people, vehicles, equipment. Each row
13
- // shows its booking links and when it's next free.
14
+ // Bookings → Rooms & people: everything people can book (rooms, people,
15
+ // vehicles, equipment). Each row shows its booking links, whether they
16
+ // confirm instantly, and when it's next free.
14
17
  if (!bookingsEnabled()) return new Response(null, { status: 404 });
15
18
  const repo = await getRepo(Astro.locals);
16
19
  const settings = await repo.getSettings();
@@ -26,8 +29,9 @@ if (Astro.request.method === 'POST') {
26
29
  const kind = String(form.get('kind') || CALENDAR.resourceKinds[0]).slice(0, 40);
27
30
  const capacity = form.get('capacity') ? int(form.get('capacity'), 1, 100000, 1) : null;
28
31
  const r = await repo.createResource({ name, slug, kind, capacity, hours: DEFAULT_HOURS, timezone: '' });
29
- // every resource starts with one ready-to-share link that confirms itself
30
- await repo.createBookingLink({ resourceId: r.id, name: kind === 'Person' ? 'Meeting' : 'Book', slug: 'book', duration: 60, step: 30, autoConfirm: true });
32
+ // every room or person starts with one ready-to-share link that follows
33
+ // the site's approval setting (Calendar settings → Booking approval)
34
+ await repo.createBookingLink({ resourceId: r.id, name: kind === 'Person' ? 'Meeting' : 'Book', slug: 'book', duration: 60, step: 30 });
31
35
  return Astro.redirect(`/admin/calendar/resources/${r.id}?created=1`);
32
36
  }
33
37
  if (op === 'move') {
@@ -40,7 +44,7 @@ if (Astro.request.method === 'POST') {
40
44
  }
41
45
  }
42
46
 
43
- const [resources, links] = await Promise.all([repo.listResources(), repo.listBookingLinks()]);
47
+ const [resources, links, pending] = await Promise.all([repo.listResources(), repo.listBookingLinks(), repo.listBookings({ from: Date.now(), statuses: ['pending'], kinds: ['booking', 'rsvp'] })]);
44
48
  const now = Date.now();
45
49
  const rows = await Promise.all(resources.map(async (r) => {
46
50
  const own = links.filter((l) => l.resourceId === r.id);
@@ -51,16 +55,11 @@ const rows = await Promise.all(resources.map(async (r) => {
51
55
  }));
52
56
  ---
53
57
 
54
- <Admin title="Resources" section="calendar:resources">
55
- <div class="page-head">
56
- <div>
57
- <h1>Resources</h1>
58
- <p class="sub">Rooms, people and things people can book · public list at <a href={CALENDAR.bookBase} target="_blank" rel="noopener">{CALENDAR.bookBase}</a></p>
59
- </div>
60
- <div class="page-actions"><button class="btn primary" type="button" data-modal="res-new">New resource</button></div>
61
- </div>
58
+ <Admin title="Rooms & people" section="calendar:bookings">
59
+ <BookingsHead active="rooms" pending={pending.length} rooms={resources.length}
60
+ sub={`What people can book, and when it’s free · public list at ${CALENDAR.bookBase}`} />
62
61
 
63
- <Modal id="res-new" title="New resource" open={Astro.url.searchParams.has('new')}>
62
+ <Modal id="new-resource" title="New room or person" open={Astro.url.searchParams.has('new')}>
64
63
  <form method="post">
65
64
  <input type="hidden" name="op" value="create" />
66
65
  <label class="field"><span>Name</span><input type="text" name="name" placeholder="Fellowship Hall" required autofocus /></label>
@@ -69,7 +68,7 @@ const rows = await Promise.all(resources.map(async (r) => {
69
68
  <label class="field"><span>Capacity <em class="hint">optional</em></span><input type="number" name="capacity" min="1" placeholder="120" /></label>
70
69
  </div>
71
70
  <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Create</button></div>
72
- <p class="hint modal-note">Starts open weekdays 9–5 with one booking link that confirms automatically. Change both on the next screen.</p>
71
+ <p class="hint modal-note">Starts open weekdays 9–5 with one booking link that follows your approval setting. Change both on the next screen.</p>
73
72
  </form>
74
73
  </Modal>
75
74
 
@@ -77,7 +76,7 @@ const rows = await Promise.all(resources.map(async (r) => {
77
76
  <div class="empty-state">
78
77
  <b>Nothing to book yet</b>
79
78
  <p>Add a room, a person’s time, or a piece of equipment. Each gets its own booking page and links.</p>
80
- <button class="btn primary" type="button" data-modal="res-new">New resource</button>
79
+ <button class="btn primary" type="button" data-modal="new-resource">Add a room or person</button>
81
80
  </div>
82
81
  ) : (
83
82
  <div class="res-list">
@@ -89,7 +88,7 @@ const rows = await Promise.all(resources.map(async (r) => {
89
88
  <a class="res-main" href={`/admin/calendar/resources/${r.id}`}>
90
89
  <b>{r.name} <span class="pill lang">{r.kind}</span>{r.visibility === 'unlisted' && <span class="pill lang">Unlisted</span>}</b>
91
90
  <small>{[r.capacity ? `Up to ${r.capacity}` : '', `${own.length} link${own.length === 1 ? '' : 's'}`, next ? `next free ${next}` : 'no openings soon'].filter(Boolean).join(' · ')}</small>
92
- <span class="res-links">{own.map((l) => <span class={`res-link${l.active ? '' : ' is-off'}`}>{l.name} · {describeLink(l)} · <em class={l.autoConfirm ? 'auto' : 'manual'}>{l.autoConfirm ? 'auto-confirm' : 'approval'}</em></span>)}</span>
91
+ <span class="res-links">{own.map((l) => { const a = linkApproval(l, settings); return <span class={`res-link${l.active ? '' : ' is-off'}`}>{l.name} · {describeLink(l)} · <em class={a.mode === 'auto' ? 'auto' : 'manual'}>{approvalShort(a)}</em></span>; })}</span>
93
92
  </a>
94
93
  <div class="cal-actions">
95
94
  <a class="icon-btn" href={resourceUrl(r.slug)} target="_blank" rel="noopener" title="View booking page" aria-label="View booking page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg></a>
@@ -7,9 +7,12 @@ import { bookingLinkUrl, bookingsEnabled } from '../../../../lib/calendar/config
7
7
  import { hoursFromForm, int, list, questionsFromForm } from '../../../../lib/booking/forms';
8
8
  import { describeLink } from '../../../../lib/booking/availability';
9
9
  import { siteOrigin } from '../../../../lib/seo';
10
+ import { APPROVAL_LABEL, linkApproval, siteApproval } from '../../../../lib/booking/approval';
11
+ import { ApprovalRules } from '../../../../lib/schema';
10
12
 
11
- // One booking link (mockup H): how people book, whether it confirms on its
12
- // own, when it's open (within the resource's hours), and what we ask.
13
+ // One booking link: how people book, how bookings get approved (follow the
14
+ // site setting, or its own mode + rules), when it's open (within the room or
15
+ // person's hours), and what we ask.
13
16
  if (!bookingsEnabled()) return new Response(null, { status: 404 });
14
17
  const repo = await getRepo(Astro.locals);
15
18
  const resource = await repo.getResource(Astro.params.id!);
@@ -33,6 +36,14 @@ if (Astro.request.method === 'POST') {
33
36
  for (let n = 2; ; n++) { const hit = await repo.getBookingLinkBySlug(resource.id, slug); if (!hit || hit.id === link.id) break; slug = `${base}-${n}`; }
34
37
  const mode = (['slots', 'range', 'day'] as const).find((m) => m === form.get('mode')) ?? 'slots';
35
38
  const lengths = list(String(form.get('lengths') || '')).map((x) => Math.round(Number(x) * 60)).filter((n) => n >= 15 && n <= 1440);
39
+ const approval = (['default', 'auto', 'rules', 'manual'] as const).find((m) => m === form.get('approval')) ?? 'default';
40
+ const approvalRules = ApprovalRules.parse({
41
+ longerThan: form.has('ruleLonger') ? int(form.get('longerThanHours'), 1, 24 * 60, 4) * 60 : 0,
42
+ firstTime: form.has('ruleFirst'),
43
+ moreSeats: form.has('ruleSeats') ? int(form.get('moreSeats'), 1, 100000, 20) : 0,
44
+ deadlineHours: form.has('deadline') ? int(form.get('deadlineHours'), 1, 24 * 60, 48) : 0,
45
+ deadlineAction: form.get('deadlineAction') === 'decline' ? 'decline' : 'approve',
46
+ });
36
47
  await repo.updateBookingLink(link.id, {
37
48
  name, slug, mode: mode as BookingMode,
38
49
  description: String(form.get('description') || '').slice(0, 1000),
@@ -47,7 +58,9 @@ if (Astro.request.method === 'POST') {
47
58
  maxAdvance: int(form.get('maxAdvance'), 1, 730, 90),
48
59
  perDayCap: int(form.get('perDayCap'), 0, 100, 0),
49
60
  hours: form.get('customHours') ? hoursFromForm(form, 'lh') : null,
50
- autoConfirm: Boolean(form.get('autoConfirm')),
61
+ approval, approvalRules,
62
+ // legacy flag for older API readers; approval is what decides
63
+ autoConfirm: linkApproval({ approval, approvalRules }, settings).mode === 'auto',
51
64
  visibility: form.get('visibility') === 'unlisted' ? 'unlisted' : 'public',
52
65
  askPhone: Boolean(form.get('askPhone')),
53
66
  questions: questionsFromForm(form),
@@ -62,12 +75,15 @@ const publicUrl = origin + bookingLinkUrl(resource.slug, link.slug);
62
75
  const embed = `<iframe src="${origin}/embed/book/${resource.slug}/${link.slug}" style="width:100%;height:620px;border:0" title="${link.name}"></iframe>`;
63
76
  const qRows = [...link.questions, ...Array.from({ length: 2 }, () => ({ id: '', label: '', type: 'text' as const, required: false, options: [] as string[], help: '' }))];
64
77
  const hours = (link.hours ?? resource.hours);
78
+ const site = siteApproval(settings);
79
+ const own = link.approval === 'default' ? site.rules : link.approvalRules;
80
+ const effective = linkApproval(link, settings).mode;
65
81
  ---
66
82
 
67
- <Admin title={link.name} section="calendar:resources">
83
+ <Admin title={link.name} section="calendar:bookings">
68
84
  <div class="page-head">
69
85
  <div>
70
- <p class="sub"><a href="/admin/calendar/resources">Resources</a> › <a href={`/admin/calendar/resources/${resource.id}`}>{resource.name}</a> ›</p>
86
+ <p class="sub"><a href="/admin/calendar/bookings">Bookings</a> › <a href="/admin/calendar/resources">Rooms &amp; people</a> › <a href={`/admin/calendar/resources/${resource.id}`}>{resource.name}</a> ›</p>
71
87
  <h1>{link.name}</h1>
72
88
  <p class="sub"><a href={publicUrl} target="_blank" rel="noopener">{publicUrl.replace(/^https?:\/\//, '')} ↗</a> · {describeLink(link)}</p>
73
89
  </div>
@@ -81,11 +97,32 @@ const hours = (link.hours ?? resource.hours);
81
97
  <form method="post" id="link-form" class="res-grid">
82
98
  <input type="hidden" name="op" value="save" />
83
99
  <div>
84
- <section class={`card card-pad confirm-card${link.autoConfirm ? ' is-auto' : ''}`}>
85
- <label class="confirm-switch">
86
- <input type="checkbox" role="switch" class="sw" name="autoConfirm" checked={link.autoConfirm} />
87
- <span><b>Confirm automatically</b><small>On: bookings are confirmed right away and show on the calendar. Off: they wait in Bookings → Unconfirmed until you approve them.</small></span>
88
- </label>
100
+ <section class="card card-pad appr" data-approval>
101
+ <div class="card-head"><b>Approval</b><span class="hint">What happens when someone books this link</span></div>
102
+ <div class="appr-choices" role="radiogroup" aria-label="Approval">
103
+ <label class="appr-choice"><input type="radio" name="approval" value="default" checked={link.approval === 'default'} />
104
+ <span><b>Use the site setting</b><small>Currently: {APPROVAL_LABEL[site.mode]}. <a href="/admin/calendar/settings#bookings">Change it</a></small></span></label>
105
+ <label class="appr-choice"><input type="radio" name="approval" value="auto" checked={link.approval === 'auto'} />
106
+ <span><b>{APPROVAL_LABEL.auto}</b><small>Booked means confirmed. It shows on your Schedule right away.</small></span></label>
107
+ <label class="appr-choice"><input type="radio" name="approval" value="rules" checked={link.approval === 'rules'} />
108
+ <span><b>{APPROVAL_LABEL.rules}</b><small>Confirm most bookings at once; hold unusual ones for you.</small></span></label>
109
+ <label class="appr-choice"><input type="radio" name="approval" value="manual" checked={link.approval === 'manual'} />
110
+ <span><b>{APPROVAL_LABEL.manual}</b><small>Every booking waits under Needs approval.</small></span></label>
111
+ </div>
112
+ <fieldset class="appr-rules" data-rules hidden={link.approval !== 'rules'}>
113
+ <legend>Ask me first when a booking is…</legend>
114
+ <label class="appr-check"><input type="checkbox" name="ruleLonger" checked={own.longerThan > 0} /> longer than <input type="number" name="longerThanHours" min="1" max="1440" value={own.longerThan ? Math.round(own.longerThan / 60) : 4} /> hours</label>
115
+ <label class="appr-check"><input type="checkbox" name="ruleFirst" checked={own.firstTime} /> the first booking from that email address</label>
116
+ <label class="appr-check"><input type="checkbox" name="ruleSeats" checked={own.moreSeats > 0} /> for more than <input type="number" name="moreSeats" min="1" max="100000" value={own.moreSeats || 20} /> people</label>
117
+ </fieldset>
118
+ <div class="appr-deadline" data-deadline hidden={link.approval === 'default' || link.approval === 'auto'}>
119
+ <label class="appr-check"><input type="checkbox" name="deadline" checked={own.deadlineHours > 0} /> If I haven’t answered in <input type="number" name="deadlineHours" min="1" max="1440" value={own.deadlineHours || 48} /> hours,</label>
120
+ <select name="deadlineAction" aria-label="What happens then">
121
+ <option value="approve" selected={own.deadlineAction === 'approve'}>approve it</option>
122
+ <option value="decline" selected={own.deadlineAction === 'decline'}>decline it</option>
123
+ </select>
124
+ </div>
125
+ <p class="hint appr-now">Right now this link {effective === 'auto' ? 'confirms every booking instantly' : effective === 'rules' ? 'confirms most bookings instantly' : 'holds every booking for approval'}.</p>
89
126
  </section>
90
127
 
91
128
  <section class="card card-pad">
@@ -175,8 +212,11 @@ const hours = (link.hours ?? resource.hours);
175
212
  const mode = (form.querySelector<HTMLInputElement>('input[name=mode]:checked')?.value) ?? 'slots';
176
213
  form.querySelectorAll<HTMLElement>('.mode-seg label').forEach((l) => l.classList.toggle('on', l.querySelector('input')?.checked ?? false));
177
214
  form.querySelectorAll<HTMLElement>('.mode-fields [data-mode]').forEach((el) => { el.hidden = !(el.dataset.mode ?? '').split(' ').includes(mode); });
178
- const auto = form.querySelector<HTMLInputElement>('input[name=autoConfirm]');
179
- form.querySelector('.confirm-card')?.classList.toggle('is-auto', Boolean(auto?.checked));
215
+ const appr = (form.querySelector<HTMLInputElement>('input[name=approval]:checked')?.value) ?? 'default';
216
+ const rules = form.querySelector<HTMLElement>('[data-rules]');
217
+ const deadline = form.querySelector<HTMLElement>('[data-deadline]');
218
+ if (rules) rules.hidden = appr !== 'rules';
219
+ if (deadline) deadline.hidden = appr === 'default' || appr === 'auto';
180
220
  };
181
221
  form.addEventListener('change', (e) => {
182
222
  sync();