@acms-subir/core 0.3.55 → 0.3.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/cli/migrations.generated.mjs +16 -4
- package/docs/calendar.md +189 -2
- package/docs/mobile.md +3 -1
- package/docs/server.md +84 -1
- package/integration.mjs +34 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
- package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
- package/mobile-template/recipes/event-request/recipe.json +21 -0
- package/package.json +1 -1
- package/src/actions/index.ts +52 -9
- package/src/components/EventPage.astro +6 -5
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/calendar/CalendarBoard.tsx +6 -3
- package/src/components/editor/event/SingleDates.tsx +87 -17
- package/src/layouts/Admin.astro +7 -0
- package/src/lib/calendar/api.ts +23 -2
- package/src/lib/calendar/board.ts +3 -2
- package/src/lib/calendar/cards.ts +4 -4
- package/src/lib/calendar/change-migration.ts +17 -3
- package/src/lib/calendar/data.ts +9 -2
- package/src/lib/calendar/event-words.ts +8 -0
- package/src/lib/calendar/events.ts +128 -15
- package/src/lib/calendar/google/map.ts +1 -1
- package/src/lib/calendar/host-requests.ts +65 -10
- package/src/lib/calendar/ics-write.ts +65 -15
- package/src/lib/calendar/member-words.ts +14 -1
- package/src/lib/calendar/moves.ts +15 -9
- package/src/lib/calendar/request-admin.ts +212 -0
- package/src/lib/calendar/request-config.ts +8 -0
- package/src/lib/calendar/request-mail.ts +158 -0
- package/src/lib/calendar/request-page.ts +52 -0
- package/src/lib/calendar/request-service.ts +535 -0
- package/src/lib/calendar/request-store.ts +2 -2
- package/src/lib/calendar/request-words.ts +263 -0
- package/src/lib/calendar/schedule.ts +42 -8
- package/src/lib/calendar/segments.ts +522 -0
- package/src/lib/editor/schedule.ts +2 -2
- package/src/lib/email/service.ts +6 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +4 -0
- package/src/lib/legal/text-en.ts +3 -0
- package/src/lib/legal/text-es.ts +3 -0
- package/src/lib/mobile/calendar.ts +1 -0
- package/src/lib/mobile/http.ts +2 -1
- package/src/lib/mobile/openapi.ts +8 -0
- package/src/lib/mobile/requests.ts +27 -0
- package/src/lib/mobile/schemas.ts +24 -6
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +3 -1
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +24 -8
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +67 -2
- package/src/lib/sections.ts +1 -1
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/admin/calendar/events/index.astro +11 -8
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/api/calendar/events/index.ts +2 -1
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/members/events/index.astro +84 -6
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +102 -4
- package/src/styles/editor.css +2 -0
- package/src/styles/site.css +29 -0
- package/src/virtual.d.ts +1 -0
|
@@ -8,7 +8,7 @@ import ContentList, { type ContentItem } from '../../../../components/ContentLis
|
|
|
8
8
|
import { getRepo } from '../../../../lib/repo';
|
|
9
9
|
import { slugify, type Status } from '../../../../lib/schema';
|
|
10
10
|
import { CALENDAR, eventUrl } from '../../../../lib/calendar/config';
|
|
11
|
-
import { featuredOccurrence, formatOccurrence, locationLine, safeColor } from '../../../../lib/calendar/events';
|
|
11
|
+
import { describeSeries, featuredOccurrence, formatOccurrence, locationLine, safeColor } from '../../../../lib/calendar/events';
|
|
12
12
|
import { defaultPlaceLocation } from '../../../../lib/maps/places';
|
|
13
13
|
import { describeRepeat } from '../../../../lib/calendar/rrule';
|
|
14
14
|
import { expandVEvents, loadFeed, tzParts } from '../../../../lib/calendar/ics';
|
|
@@ -25,6 +25,7 @@ import { approveEventRequest, declineEventRequest, pendingRequests } from '../..
|
|
|
25
25
|
import { approveDateRequest, dateRequestClashes, dateRequestTimes, declineDateRequest, type MoveRequestData } from '../../../../lib/calendar/host-requests';
|
|
26
26
|
import { mergeEventDraft } from '../../../../lib/drafts';
|
|
27
27
|
import { ApiError } from '../../../../lib/api';
|
|
28
|
+
import { REQUESTS } from '../../../../lib/calendar/request-config';
|
|
28
29
|
|
|
29
30
|
// Schedule → List: upcoming first, grouped by the month each event next
|
|
30
31
|
// happens. Subscribed calendars appear as read-only rows with a "Make an
|
|
@@ -96,10 +97,11 @@ if (Astro.request.method === 'POST') {
|
|
|
96
97
|
try {
|
|
97
98
|
// a host's date move (host-requests.ts): `id` is the request's
|
|
98
99
|
if (op === 'approve-date') {
|
|
99
|
-
const { moved } = await approveDateRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
|
|
100
|
-
const
|
|
100
|
+
const { moved, changed } = await approveDateRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
|
|
101
|
+
const done = (moved ?? changed)!;
|
|
102
|
+
const n = done.clashes.length;
|
|
101
103
|
const shared = n ? ', and it shares a room with ' + n + ' other booking' + (n === 1 ? '' : 's') + ' at that time' : '';
|
|
102
|
-
return toRequests('Approved. “' +
|
|
104
|
+
return toRequests('Approved. “' + done.event.title + '” ' + (moved ? 'has the date moved' : 'is changed from that date on') + shared + '.');
|
|
103
105
|
}
|
|
104
106
|
if (op === 'decline-date') {
|
|
105
107
|
await declineDateRequest(repo, who, id, String(form.get('reply') || ''), hooks, Number(form.get('at')) || undefined);
|
|
@@ -232,7 +234,7 @@ const selected = rows
|
|
|
232
234
|
const items: ContentItem[] = selected.map(({ e, o }) => {
|
|
233
235
|
const f = o ? formatOccurrence(o, tz, locale) : null;
|
|
234
236
|
const cals = e.calendarIds.map((id) => calById.get(id)?.name).filter(Boolean);
|
|
235
|
-
const repeats =
|
|
237
|
+
const repeats = describeSeries(e, settings, now);
|
|
236
238
|
return {
|
|
237
239
|
id: e.id,
|
|
238
240
|
href: `/admin/calendar/events/${e.id}`,
|
|
@@ -306,8 +308,8 @@ const dateRows = scope !== 'requests' ? [] : await Promise.all(waitingDates
|
|
|
306
308
|
const clash = clashes.length ? `${[...new Set(clashes.map((c) => roomNames.get(c.resourceId) ?? 'A room'))].join(', ')} also held then by ${[...new Set(clashes.map((c) => c.title || 'something else'))].slice(0, 3).join(', ')}` : '';
|
|
307
309
|
return {
|
|
308
310
|
id: r.id, href: `/admin/calendar/events/${e.id}`, title: e.title || 'Untitled event', at: r.updatedAt, host,
|
|
309
|
-
...(await dateRequestTimes(repo, r)), back: Boolean(d.back),
|
|
310
|
-
line: `${d.back ? 'Move back' : 'Date move'} from ${host} · ${ago(r.createdAt)}`, note: r.note, publicNote: d.note, clash,
|
|
311
|
+
...(await dateRequestTimes(repo, r)), back: Boolean(d.back), label: r.kind === 'from-date' ? 'From this date on' : d.back ? 'Move back' : 'Date move',
|
|
312
|
+
line: `${r.kind === 'from-date' ? 'Change from this date on' : d.back ? 'Move back' : 'Date move'} from ${host} · ${ago(r.createdAt)}`, note: r.note, publicNote: d.note, clash,
|
|
311
313
|
};
|
|
312
314
|
}));
|
|
313
315
|
|
|
@@ -510,7 +512,7 @@ const subBits = [
|
|
|
510
512
|
{r.publicNote && <small>Note on the date: {r.publicNote}</small>}
|
|
511
513
|
{r.clash && <small class="ev-req-clash">{r.clash}</small>}
|
|
512
514
|
</a>
|
|
513
|
-
<span class="bk-st is-pending">{r.
|
|
515
|
+
<span class="bk-st is-pending">{r.label}</span>
|
|
514
516
|
<div class="bk-acts">
|
|
515
517
|
<a class="btn sm ghost" href={r.href}>Open</a>
|
|
516
518
|
{canApprove && (
|
|
@@ -527,6 +529,7 @@ const subBits = [
|
|
|
527
529
|
))}
|
|
528
530
|
</section>
|
|
529
531
|
)}
|
|
532
|
+
{REQUESTS.enabled && <p class="hint">Every request, including those from the app and the website, is on <a href="/admin/calendar/requests">Requests</a>.</p>}
|
|
530
533
|
{(requestRows.length > 0 || dateRows.length > 0) && !canApprove && <p class="hint">Someone whose role can publish content approves these.</p>}
|
|
531
534
|
{canApprove && dateRows.map((r) => (
|
|
532
535
|
<Modal id={`decline-date-${r.id}`} title="Decline this date change">
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../layouts/Admin.astro';
|
|
3
|
+
import Modal from '../../../components/Modal.astro';
|
|
4
|
+
import { getRepo } from '../../../lib/repo';
|
|
5
|
+
import { ApiError } from '../../../lib/api';
|
|
6
|
+
import { extraLocales, localeName, localePath } from '../../../lib/i18n';
|
|
7
|
+
import { PUSH } from '../../../lib/push/config';
|
|
8
|
+
import { accessFor, forbiddenUnless } from '../../../lib/permissions/access';
|
|
9
|
+
import type { Permission } from '../../../lib/permissions/catalog';
|
|
10
|
+
import { REQUESTS } from '../../../lib/calendar/request-config';
|
|
11
|
+
import { MEMBERS } from '../../../lib/calendar/members-config';
|
|
12
|
+
import { approveRequest, declineRequest, deleteRequest, replyToRequest, requestRecipients, requestableRooms, requestSettings } from '../../../lib/calendar/request-service';
|
|
13
|
+
import { pendingCount, requestRows, settingsFromForm, type RequestTab } from '../../../lib/calendar/request-admin';
|
|
14
|
+
import { approveEventRequest, declineEventRequest } from '../../../lib/calendar/requests';
|
|
15
|
+
|
|
16
|
+
// Calendar → Requests (acms({ requests })): requests from the app, the
|
|
17
|
+
// website and hosts in one list, with Pending, Approved and Declined tabs and
|
|
18
|
+
// what takes the rooms on each row. Approve opens a filled-in draft (or the
|
|
19
|
+
// event a change is about); Reply and Decline email the person in their
|
|
20
|
+
// language. Reading needs "Create and edit content", answering "Publish
|
|
21
|
+
// content", the settings "Manage rooms and calendars". The rules are
|
|
22
|
+
// lib/calendar/request-service.ts; the list lib/calendar/request-admin.ts.
|
|
23
|
+
if (!REQUESTS.enabled) return new Response(null, { status: 404 });
|
|
24
|
+
const repo = await getRepo(Astro.locals);
|
|
25
|
+
const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'content.edit');
|
|
26
|
+
if (denied) return denied;
|
|
27
|
+
const access = (await accessFor(Astro.request, Astro.cookies, repo))!;
|
|
28
|
+
const who = { id: access.userId, can: (p: string) => access.can(p as Permission) };
|
|
29
|
+
const hooks = { url: Astro.url, locals: Astro.locals };
|
|
30
|
+
const back = (notice: string, tab = Astro.url.searchParams.get('tab') ?? '') => {
|
|
31
|
+
const p = new URLSearchParams();
|
|
32
|
+
if (tab) p.set('tab', tab);
|
|
33
|
+
if (notice) p.set('notice', notice);
|
|
34
|
+
const q = p.toString();
|
|
35
|
+
return Astro.redirect(q ? '/admin/calendar/requests?' + q : '/admin/calendar/requests');
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (Astro.request.method === 'POST') {
|
|
39
|
+
const form = await Astro.request.formData();
|
|
40
|
+
const op = String(form.get('op') ?? '');
|
|
41
|
+
const id = String(form.get('id') ?? '');
|
|
42
|
+
const seen = Number(form.get('at')) || undefined;
|
|
43
|
+
const nope = await forbiddenUnless(Astro.request, Astro.cookies, repo, op === 'settings' ? 'schedule.manage' : 'content.publish');
|
|
44
|
+
if (nope) return nope;
|
|
45
|
+
try {
|
|
46
|
+
if (op === 'approve') {
|
|
47
|
+
const done = await approveRequest(repo, who, id, hooks, seen, { message: String(form.get('message') ?? ''), email: form.get('email') === '1' });
|
|
48
|
+
return Astro.redirect(done.next);
|
|
49
|
+
}
|
|
50
|
+
if (op === 'decline') {
|
|
51
|
+
// the email choice is only offered for requests from the app or the website (hosts are always told)
|
|
52
|
+
const email = form.has('email-offered') ? form.get('email') === '1' : true;
|
|
53
|
+
await declineRequest(repo, who, id, String(form.get('reason') ?? ''), hooks, seen, { email });
|
|
54
|
+
return back(email ? 'Declined. The person was emailed.' : 'Declined, without an email.');
|
|
55
|
+
}
|
|
56
|
+
if (op === 'delete') {
|
|
57
|
+
await deleteRequest(repo, who, id);
|
|
58
|
+
return back('Deleted as spam. Nobody was emailed.');
|
|
59
|
+
}
|
|
60
|
+
if (op === 'reply') {
|
|
61
|
+
await replyToRequest(repo, who, id, String(form.get('message') ?? ''), hooks);
|
|
62
|
+
return back('Reply sent.');
|
|
63
|
+
}
|
|
64
|
+
// members' requests staged on their events (My events)
|
|
65
|
+
if (op === 'member-approve') {
|
|
66
|
+
const e = await approveEventRequest(repo, who, id, hooks, seen);
|
|
67
|
+
return back(`Approved “${e.title}”.`);
|
|
68
|
+
}
|
|
69
|
+
if (op === 'member-decline') {
|
|
70
|
+
const e = await declineEventRequest(repo, who, id, String(form.get('reason') ?? ''), hooks, seen);
|
|
71
|
+
return back(`Declined “${e.title}”.`);
|
|
72
|
+
}
|
|
73
|
+
if (op === 'settings') {
|
|
74
|
+
const settings = await repo.getSettings();
|
|
75
|
+
const calendars = (await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id);
|
|
76
|
+
const requests = settingsFromForm(form, requestSettings(settings), calendars);
|
|
77
|
+
await repo.updateSettings({ calendar: { ...settings.calendar, requests } });
|
|
78
|
+
return back('Request settings saved.');
|
|
79
|
+
}
|
|
80
|
+
return back('');
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return back(err instanceof ApiError ? err.message : 'Couldn’t do that. Try again.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const settings = await repo.getSettings();
|
|
87
|
+
const rq = requestSettings(settings);
|
|
88
|
+
const tab = (['pending', 'approved', 'declined'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'pending';
|
|
89
|
+
const page = Math.min(1000, Math.max(1, Number.parseInt(Astro.url.searchParams.get('page') ?? '', 10) || 1));
|
|
90
|
+
const [{ rows, more }, waiting, rooms, calendars, recipients] = await Promise.all([
|
|
91
|
+
requestRows(repo, settings, tab as RequestTab, Date.now(), page), pendingCount(repo), requestableRooms(repo),
|
|
92
|
+
repo.listCalendars().then((cs) => cs.filter((c) => c.kind === 'native')), requestRecipients(repo, settings),
|
|
93
|
+
]);
|
|
94
|
+
const notice = Astro.url.searchParams.get('notice') ?? '';
|
|
95
|
+
const canAnswer = access.can('content.publish');
|
|
96
|
+
const canSet = access.can('schedule.manage');
|
|
97
|
+
const langs = extraLocales();
|
|
98
|
+
// the kinds there are, plus a few empty rows to add more (12 at most)
|
|
99
|
+
const typeRows = [...rq.types, ...Array.from({ length: Math.min(3, 12 - rq.types.length) }, () => null)];
|
|
100
|
+
const TABS = [['pending', 'Pending'], ['approved', 'Approved'], ['declined', 'Declined']] as const;
|
|
101
|
+
const formUrl = REQUESTS.page ? REQUESTS.path : '';
|
|
102
|
+
const pageHref = (n: number) => '/admin/calendar/requests?' + new URLSearchParams({ ...(tab === 'pending' ? {} : { tab }), ...(n > 1 ? { page: String(n) } : {}) }).toString();
|
|
103
|
+
const empty = { pending: 'Nothing waiting. Requests from the app, the website and hosts show up here.', approved: 'No approved requests yet.', declined: 'No declined requests.' }[tab];
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
<Admin title="Requests" section="calendar:requests">
|
|
107
|
+
<div class="page-head">
|
|
108
|
+
<div>
|
|
109
|
+
<h1>Requests</h1>
|
|
110
|
+
<p class="sub">From the app, the website{MEMBERS.enabled ? ' and hosts' : ''}, in one list{formUrl && <> · the form is at <a href={formUrl} target="_blank" rel="noopener">{formUrl}</a>{langs.map((l) => <> and <a href={localePath(formUrl, l)} target="_blank" rel="noopener">{localePath(formUrl, l)}</a></>)}</>}</p>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="page-actions">
|
|
113
|
+
{!rq.open && <span class="rq-closed">Closed</span>}
|
|
114
|
+
{canSet && <button class="btn" type="button" data-modal="rq-settings">Settings</button>}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{notice && <p class="notice" role="status">{notice}</p>}
|
|
119
|
+
|
|
120
|
+
<nav class="seg rq-tabs" aria-label="Show">
|
|
121
|
+
{TABS.map(([k, l]) => (
|
|
122
|
+
<a href={k === 'pending' ? '/admin/calendar/requests' : '/admin/calendar/requests?tab=' + k} class={tab === k ? 'on' : ''} aria-current={tab === k ? 'page' : undefined}>
|
|
123
|
+
{l}{k === 'pending' && waiting > 0 && <span class="rq-count">{waiting}</span>}
|
|
124
|
+
</a>
|
|
125
|
+
))}
|
|
126
|
+
</nav>
|
|
127
|
+
|
|
128
|
+
{rows.length === 0 ? <p class="hint rq-empty">{empty}</p> : (
|
|
129
|
+
<section class="card rq-list">
|
|
130
|
+
{rows.map((r) => (
|
|
131
|
+
<article class="rq-row" id={r.dom}>
|
|
132
|
+
<small class="rq-head">{r.head}</small>
|
|
133
|
+
<b class="rq-title">{r.href ? <a href={r.href}>{r.title}</a> : r.title}</b>
|
|
134
|
+
{r.line && <span class="rq-line">{r.line}</span>}
|
|
135
|
+
<span class="rq-who">{r.who}</span>
|
|
136
|
+
{r.note && <p class="rq-note">“{r.note}”</p>}
|
|
137
|
+
{r.clash && <p class={r.clash.taken ? 'rq-clash is-taken' : 'rq-clash'}>{r.clash.text}</p>}
|
|
138
|
+
{r.answer && <p class="rq-answer">{r.answer}{r.reply && <> — “{r.reply}”</>}</p>}
|
|
139
|
+
{tab === 'pending' && canAnswer && (
|
|
140
|
+
<div class="rq-acts">
|
|
141
|
+
{(r.kind === 'new' || r.kind === 'change') && <button class="btn sm primary" type="button" data-modal={'approve-' + r.dom}>Approve…</button>}
|
|
142
|
+
{(r.kind === 'move' || r.kind === 'from-date') && <form method="post"><input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>}
|
|
143
|
+
{(r.kind === 'move' || r.kind === 'from-date') && r.href && <a class="btn sm ghost" href={r.href}>Open the event</a>}
|
|
144
|
+
{r.kind === 'member-new' || r.kind === 'member-changes' ? (
|
|
145
|
+
<>
|
|
146
|
+
<a class="btn sm ghost" href={r.href}>Open</a>
|
|
147
|
+
<form method="post"><input type="hidden" name="op" value="member-approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>
|
|
148
|
+
</>
|
|
149
|
+
) : r.canReply && r.email && <button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button>}
|
|
150
|
+
<button class="btn sm ghost" type="button" data-modal={'decline-' + r.dom}>Decline…</button>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
{tab !== 'pending' && r.canReply && r.email && canAnswer && <div class="rq-acts"><button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button></div>}
|
|
154
|
+
</article>
|
|
155
|
+
))}
|
|
156
|
+
</section>
|
|
157
|
+
)}
|
|
158
|
+
{(page > 1 || more) && (
|
|
159
|
+
<nav class="rq-pager" aria-label="Pages">
|
|
160
|
+
{page > 1 && <a class="btn sm ghost" href={pageHref(page - 1)}>Previous</a>}
|
|
161
|
+
<span class="hint">Page {page}</span>
|
|
162
|
+
{more && <a class="btn sm ghost" href={pageHref(page + 1)}>Next</a>}
|
|
163
|
+
</nav>
|
|
164
|
+
)}
|
|
165
|
+
{tab === 'pending' && rows.length > 0 && !canAnswer && <p class="hint">Someone whose role can publish content answers these.</p>}
|
|
166
|
+
|
|
167
|
+
{canAnswer && rows.map((r) => (
|
|
168
|
+
<>
|
|
169
|
+
{tab === 'pending' && (r.kind === 'new' || r.kind === 'change') && (
|
|
170
|
+
<Modal id={'approve-' + r.dom} title={r.kind === 'new' ? 'Approve and make a draft' : 'Approve this change'}>
|
|
171
|
+
<form method="post">
|
|
172
|
+
<input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
|
|
173
|
+
<p class="hint">{r.kind === 'new'
|
|
174
|
+
? 'You’ll get a draft with the title, time, repeat, rooms held and the kind’s tag and calendar. Check it, then publish it. Their contact details stay here, never on the event.'
|
|
175
|
+
: 'The event opens (or that date’s Move… dialog) so you can make the change.'}</p>
|
|
176
|
+
<label class="field"><span>Message <em class="hint">optional</em></span><textarea name="message" rows="3" maxlength="2000" placeholder="See you then!"></textarea></label>
|
|
177
|
+
<label class="check-row"><input type="checkbox" name="email" value="1" checked /> Email them that it’s approved, in their language</label>
|
|
178
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Approve</button></div>
|
|
179
|
+
</form>
|
|
180
|
+
</Modal>
|
|
181
|
+
)}
|
|
182
|
+
{r.canReply && r.email && (
|
|
183
|
+
<Modal id={'reply-' + r.dom} title="Reply">
|
|
184
|
+
<form method="post">
|
|
185
|
+
<input type="hidden" name="op" value="reply" /><input type="hidden" name="id" value={r.id} />
|
|
186
|
+
<p class="hint">Emailed to {r.email} with a short line in their language. The request stays {tab === 'pending' ? 'waiting' : tab}.</p>
|
|
187
|
+
<label class="field"><span>Your reply</span><textarea name="message" rows="5" maxlength="2000" required placeholder="Could you come at 3 instead? The room is free then."></textarea></label>
|
|
188
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Send reply</button></div>
|
|
189
|
+
</form>
|
|
190
|
+
</Modal>
|
|
191
|
+
)}
|
|
192
|
+
{tab === 'pending' && (
|
|
193
|
+
<Modal id={'decline-' + r.dom} title="Decline this request">
|
|
194
|
+
<form method="post">
|
|
195
|
+
<input type="hidden" name="op" value={r.kind === 'member-new' || r.kind === 'member-changes' ? 'member-decline' : 'decline'} /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
|
|
196
|
+
<p class="hint">{r.kind === 'member-new' || r.kind === 'member-changes' || r.kind === 'move' || r.kind === 'from-date'
|
|
197
|
+
? 'They’re emailed that it can’t go ahead' + (r.kind === 'member-new' ? '; the draft stays for them to change and send again.' : r.kind === 'move' ? '; the date stays as it is.' : r.kind === 'from-date' ? '; the schedule stays as it is.' : '.')
|
|
198
|
+
: 'It moves to Declined, and is deleted with the rest after the time you keep requests.'}</p>
|
|
199
|
+
<label class="field"><span>Reason <em class="hint">optional</em></span><textarea name="reason" rows="3" maxlength="2000" placeholder="The Sanctuary is booked that weekend."></textarea></label>
|
|
200
|
+
{(r.kind === 'new' || r.kind === 'change') && <label class="check-row"><input type="hidden" name="email-offered" value="1" /><input type="checkbox" name="email" value="1" checked /> Email them, in their language</label>}
|
|
201
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Decline</button></div>
|
|
202
|
+
</form>
|
|
203
|
+
{(r.kind === 'new' || r.kind === 'change') && (
|
|
204
|
+
<form method="post" class="rq-spam">
|
|
205
|
+
<input type="hidden" name="op" value="delete" /><input type="hidden" name="id" value={r.id} />
|
|
206
|
+
<p class="hint">Spam? Delete it now, without an email to the address it gave.</p>
|
|
207
|
+
<button class="btn sm ghost" type="submit">Delete as spam</button>
|
|
208
|
+
</form>
|
|
209
|
+
)}
|
|
210
|
+
</Modal>
|
|
211
|
+
)}
|
|
212
|
+
</>
|
|
213
|
+
))}
|
|
214
|
+
|
|
215
|
+
{canSet && (
|
|
216
|
+
<Modal id="rq-settings" title="Request settings" open={Astro.url.searchParams.has('settings')}>
|
|
217
|
+
<form method="post" class="rq-settings">
|
|
218
|
+
<input type="hidden" name="op" value="settings" />
|
|
219
|
+
<div>
|
|
220
|
+
<label class="check-row"><input type="checkbox" name="open" value="1" checked={rq.open} /> Taking requests</label>
|
|
221
|
+
<p class="hint">When off, the form and the app say so and show the office phone. They never disappear.</p>
|
|
222
|
+
</div>
|
|
223
|
+
<fieldset>
|
|
224
|
+
<legend>Kinds people choose from</legend>
|
|
225
|
+
<p class="hint">Approving a request makes a draft with the kind’s tag, in its calendar (else the default calendar).</p>
|
|
226
|
+
<div class="rq-type rq-type-h" aria-hidden="true"><span>Kind</span>{langs.map((l) => <span>{localeName(l, true)}</span>)}<span>Tag</span><span>Calendar</span></div>
|
|
227
|
+
{typeRows.map((t, i) => (
|
|
228
|
+
<div class="rq-type">
|
|
229
|
+
{t && <input type="hidden" name={'type-id-' + i} value={t.id} />}
|
|
230
|
+
<input name={'type-label-' + i} value={t?.label ?? ''} placeholder={t || i > rq.types.length ? '' : rq.types.length ? 'Add a kind' : 'Class'} aria-label="Kind" maxlength="60" />
|
|
231
|
+
{langs.map((l) => <input name={'type-' + l + '-' + i} value={t?.translations[l] ?? ''} placeholder={localeName(l, true)} aria-label={'Kind in ' + localeName(l)} maxlength="60" />)}
|
|
232
|
+
<input name={'type-tag-' + i} value={t?.tag ?? ''} placeholder="Tag" aria-label="Tag" maxlength="60" />
|
|
233
|
+
<select name={'type-cal-' + i} aria-label="Calendar">
|
|
234
|
+
<option value="">Default calendar</option>
|
|
235
|
+
{calendars.map((c) => <option value={c.id} selected={t?.calendarId === c.id}>{c.name}</option>)}
|
|
236
|
+
</select>
|
|
237
|
+
</div>
|
|
238
|
+
))}
|
|
239
|
+
</fieldset>
|
|
240
|
+
<label class="field"><span>Reply time <em class="hint">“We usually answer within …”</em></span><input name="replyTime" value={rq.replyTime} placeholder="3 days" maxlength="60" /></label>
|
|
241
|
+
<label class="field"><span>Notice at the top of the form <em class="hint">optional</em></span><textarea name="notice" rows="2" maxlength="400" placeholder="The church office reviews every request. It isn’t on the calendar until they confirm it.">{rq.notice}</textarea></label>
|
|
242
|
+
{langs.map((l) => (
|
|
243
|
+
<fieldset>
|
|
244
|
+
<legend>{localeName(l, true)}</legend>
|
|
245
|
+
<label class="field"><span>Reply time</span><input name={'replyTime-' + l} value={rq.translations[l]?.replyTime ?? ''} maxlength="60" /></label>
|
|
246
|
+
<label class="field"><span>Notice</span><textarea name={'notice-' + l} rows="2" maxlength="400">{rq.translations[l]?.notice ?? ''}</textarea></label>
|
|
247
|
+
</fieldset>
|
|
248
|
+
))}
|
|
249
|
+
<label class="field"><span>Office phone <em class="hint">shown when requests are closed, and after sending</em></span><input name="phone" inputmode="tel" autocomplete="off" value={rq.phone} maxlength="40" /></label>
|
|
250
|
+
<label class="field"><span>Who hears about new requests</span><textarea name="notify" rows="2" placeholder={recipients.join(', ')}>{rq.notify.join(', ')}</textarea><em class="hint">Email addresses. Empty: {MEMBERS.notify.length ? 'the members notify list in the site’s config' : 'the owners'} ({recipients.join(', ') || 'nobody yet'}).</em></label>
|
|
251
|
+
{PUSH.enabled && (
|
|
252
|
+
<div>
|
|
253
|
+
<label class="check-row"><input type="hidden" name="phones" value="0" /><input type="checkbox" name="phones" value="1" checked={rq.phones} /> Also notify their linked phones</label>
|
|
254
|
+
<p class="hint">A push saying a request arrived and for when, without names.</p>
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
<div class="field-row">
|
|
258
|
+
<label class="field"><span>New requests a day <em class="hint">0 = no limit</em></span><input name="perDay" type="number" min="0" max="10000" value={rq.perDay} /></label>
|
|
259
|
+
<label class="field"><span>Keep requests for (days) <em class="hint">after the answer, or after they arrive if nobody answers</em></span><input name="keepDays" type="number" min="7" max="1095" value={rq.keepDays} /></label>
|
|
260
|
+
</div>
|
|
261
|
+
<p class="hint">Rooms people can ask for: {rooms.length ? rooms.map((r) => r.name).join(', ') : 'none yet'}. Turn on “Anyone can check if it’s free” on a room to offer it.</p>
|
|
262
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Save</button></div>
|
|
263
|
+
</form>
|
|
264
|
+
</Modal>
|
|
265
|
+
)}
|
|
266
|
+
</Admin>
|
|
267
|
+
|
|
268
|
+
<style>
|
|
269
|
+
.rq-tabs { margin: 0 0 14px; }
|
|
270
|
+
.rq-count { font-size: 11px; font-weight: 700; color: var(--amber); background: var(--amber-soft); border-radius: var(--r-full); padding: 0 6px; margin-left: 6px; }
|
|
271
|
+
.rq-closed { font-size: 12.5px; font-weight: 600; color: var(--amber); background: var(--amber-soft); border-radius: var(--r-full); padding: 3px 10px; }
|
|
272
|
+
.rq-empty { padding: 24px 8px; }
|
|
273
|
+
.rq-list { padding: 0; }
|
|
274
|
+
.rq-row { display: grid; gap: 3px; padding: 14px 18px; border-top: 1px solid var(--line); scroll-margin-top: 80px; }
|
|
275
|
+
.rq-row:first-child { border-top: 0; }
|
|
276
|
+
.rq-row:target { background: var(--amber-soft); }
|
|
277
|
+
.rq-head { color: var(--text-2); font-size: 12.5px; }
|
|
278
|
+
.rq-title { font-size: 15.5px; }
|
|
279
|
+
.rq-title a { color: inherit; }
|
|
280
|
+
.rq-line, .rq-who { font-size: 13.5px; color: var(--text-2); }
|
|
281
|
+
.rq-note { margin: 4px 0 0; font-style: italic; font-size: 14px; }
|
|
282
|
+
.rq-clash { margin: 4px 0 0; font-size: 13px; color: var(--green); }
|
|
283
|
+
.rq-clash.is-taken { color: var(--amber); }
|
|
284
|
+
.rq-answer { margin: 4px 0 0; font-size: 13px; color: var(--text-2); }
|
|
285
|
+
.rq-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
|
286
|
+
.rq-acts form { margin: 0; }
|
|
287
|
+
.rq-pager { display: flex; align-items: center; gap: 10px; margin: 12px 0 0; }
|
|
288
|
+
.rq-spam { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--line); margin-top: 14px; padding-top: 12px; }
|
|
289
|
+
.rq-spam .hint { margin: 0; }
|
|
290
|
+
.rq-settings { display: grid; gap: 12px; }
|
|
291
|
+
.rq-settings fieldset { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin: 0; display: grid; gap: 8px; }
|
|
292
|
+
.rq-settings legend { font-weight: 600; font-size: 13.5px; padding: 0 4px; }
|
|
293
|
+
.rq-settings .hint { margin: 2px 0 0; }
|
|
294
|
+
.rq-type { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; }
|
|
295
|
+
.rq-type > * { min-width: 0; }
|
|
296
|
+
.rq-type-h span { font-size: 12px; color: var(--text-2); }
|
|
297
|
+
:global(dialog#rq-settings.modal) { width: min(680px, calc(100vw - 32px)); max-width: none; }
|
|
298
|
+
</style>
|
|
@@ -106,8 +106,9 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
|
106
106
|
from: { start: o.allDay ? isoWithOffset(o.moved.from.start, etz).slice(0, 10) : isoWithOffset(o.moved.from.start, etz), end: o.allDay ? isoWithOffset(o.moved.from.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.moved.from.end, etz) },
|
|
107
107
|
note: o.moved.note, location: o.moved.location ?? null,
|
|
108
108
|
} : null,
|
|
109
|
-
// that date's rooms
|
|
109
|
+
// that date's rooms, and its own place (moved there, or from its segment; null = the event's)
|
|
110
110
|
rooms: roomsToJson(o.roomIds, resources, full),
|
|
111
|
+
location: o.location ?? null,
|
|
111
112
|
event: toJson(o.event.id),
|
|
112
113
|
};
|
|
113
114
|
}),
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { postRequest } from '../../../../lib/mobile/requests';
|
|
2
|
+
import { privateRoute } from '../../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../lib/mobile/http';
|
|
5
|
+
export const POST = privateRoute(postRequest, { write: true, status: 201 });
|
|
6
|
+
export const ALL = () => methodNotAllowed('POST, OPTIONS');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getRequestOptions } from '../../../../lib/mobile/requests';
|
|
2
|
+
import { privateRoute } from '../../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../lib/mobile/http';
|
|
5
|
+
// never cached: closing requests or marking a room requestable shows at once
|
|
6
|
+
export const GET = privateRoute(getRequestOptions);
|
|
7
|
+
export const ALL = () => methodNotAllowed('GET, OPTIONS');
|
|
@@ -5,9 +5,11 @@ import { currentUser } from '../../../server';
|
|
|
5
5
|
import { coded } from '../../../lib/api';
|
|
6
6
|
import { readForm } from '../../../lib/forms/http';
|
|
7
7
|
import { MEMBERS, memberEventsPath, hostedEvents, withdrawEventRequest, dismissEventRequest, setHostedOccurrence } from '../../../lib/calendar/requests';
|
|
8
|
-
import { dismissDateRequest, moveHostedDate, moveHostedDateBack, myDateRequests, requestDateMove, withdrawDateRequest, type MoveRequestData } from '../../../lib/calendar/host-requests';
|
|
8
|
+
import { dismissDateRequest, moveHostedDate, moveHostedDateBack, myDateRequests, requestDateMove, requestFromDate, withdrawDateRequest, type FromDateRequestData, type MoveRequestData } from '../../../lib/calendar/host-requests';
|
|
9
|
+
import { changeFromDate, changeFromDefaults, type ChangeFromInput } from '../../../lib/calendar/segments';
|
|
10
|
+
import { rruleMap, WEEKDAY_CODES, weekdayOf } from '../../../lib/calendar/rrule';
|
|
9
11
|
import { eventUrl } from '../../../lib/calendar/config';
|
|
10
|
-
import { eventIsPublic, eventRoomIds, eventTimeZone, expandEvents, featuredOccurrence, findOccurrence, formatOccurrence, type Occurrence } from '../../../lib/calendar/events';
|
|
12
|
+
import { describeSeries, eventIsPublic, eventRoomIds, eventTimeZone, expandEvents, featuredOccurrence, findOccurrence, formatOccurrence, type Occurrence } from '../../../lib/calendar/events';
|
|
11
13
|
import { tzParts } from '../../../lib/calendar/ics';
|
|
12
14
|
import { describeRepeat } from '../../../lib/calendar/rrule';
|
|
13
15
|
import { requestLabel } from '../../../lib/calendar/member-form';
|
|
@@ -57,6 +59,25 @@ function nextDay(day: string): string {
|
|
|
57
59
|
return d.toISOString().slice(0, 10);
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The "Change from this date on" form → ChangeFromInput: the first date and
|
|
64
|
+
* times as for a move, and a repeat choice worded around that first date
|
|
65
|
+
* ('same' keeps the rule in effect, moved to its weekday; the others end as
|
|
66
|
+
* that rule does).
|
|
67
|
+
*/
|
|
68
|
+
async function fromDateInput(form: FormData, e: CalEvent | null, key: string): Promise<ChangeFromInput> {
|
|
69
|
+
const base = moveInput(form, e);
|
|
70
|
+
const choice = String(form.get('repeat') ?? 'same');
|
|
71
|
+
if (choice === 'same' || !e) return base;
|
|
72
|
+
const date = String(form.get('date') ?? '').trim();
|
|
73
|
+
const wd = weekdayOf(date);
|
|
74
|
+
const code = WEEKDAY_CODES[wd.dow];
|
|
75
|
+
const kept = rruleMap((await changeFromDefaults(repo, e.id, key))?.rrule ?? '');
|
|
76
|
+
const ends = kept.COUNT ? `;COUNT=${kept.COUNT}` : kept.UNTIL ? `;UNTIL=${kept.UNTIL}` : '';
|
|
77
|
+
const rule = { weekly: `FREQ=WEEKLY;BYDAY=${code}`, biweekly: `FREQ=WEEKLY;INTERVAL=2;BYDAY=${code}`, monthly: `FREQ=MONTHLY;BYDAY=${wd.nth >= 5 ? -1 : wd.nth}${code}`, daily: 'FREQ=DAILY' }[choice];
|
|
78
|
+
return { ...base, rrule: choice === 'once' ? '' : rule ? rule + ends : undefined };
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
let error = '';
|
|
61
82
|
// a move refused because a room is taken: shown under its date, with "Send as a request"
|
|
62
83
|
let clash: { id: string; key: string; back: boolean; message: string; input: MoveInput | null } | null = null;
|
|
@@ -108,6 +129,16 @@ if (Astro.request.method === 'POST') {
|
|
|
108
129
|
clash = { id, key, back: true, message: failure(err, w, lang).message, input: null };
|
|
109
130
|
Astro.response.status = 409;
|
|
110
131
|
}
|
|
132
|
+
} else if (op === 'from-date') {
|
|
133
|
+
// staff who host apply it; other hosts send it to them
|
|
134
|
+
posted = { id, key, form };
|
|
135
|
+
const input = await fromDateInput(form, await repo.getEvent(id), key);
|
|
136
|
+
if (user.can('content.edit')) {
|
|
137
|
+
await changeFromDate(repo, user, id, key, input, hooks);
|
|
138
|
+
return redirectTo(`${home}?fromDateDone=1`);
|
|
139
|
+
}
|
|
140
|
+
await requestFromDate(repo, user, id, key, input, String(form.get('message') ?? ''), hooks);
|
|
141
|
+
return redirectTo(`${home}?fromDateSent=1`);
|
|
111
142
|
} else if (op === 'withdraw-request') {
|
|
112
143
|
await withdrawDateRequest(repo, user, String(form.get('request') ?? ''));
|
|
113
144
|
return redirectTo(`${home}?withdrawn=1`);
|
|
@@ -122,7 +153,7 @@ if (Astro.request.method === 'POST') {
|
|
|
122
153
|
}
|
|
123
154
|
}
|
|
124
155
|
|
|
125
|
-
const FLASH: [keyof typeof w.flash, string][] = (['sent', 'updated', 'changes', 'withdrawn', 'cancelled', 'restored', 'moved', 'movedBack', 'moveSent', 'fromDateSent'] as const).map((k) => [k, w.flash[k]]);
|
|
156
|
+
const FLASH: [keyof typeof w.flash, string][] = (['sent', 'updated', 'changes', 'withdrawn', 'cancelled', 'restored', 'moved', 'movedBack', 'moveSent', 'fromDateSent', 'fromDateDone'] as const).map((k) => [k, w.flash[k]]);
|
|
126
157
|
const flash = error || clash ? '' : FLASH.find(([k]) => Astro.url.searchParams.has(k))?.[1] ?? '';
|
|
127
158
|
|
|
128
159
|
const DAY = 86_400_000;
|
|
@@ -147,7 +178,8 @@ const when = (o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>
|
|
|
147
178
|
};
|
|
148
179
|
const day = (ms: number) => format({ month: 'short', day: 'numeric' }, siteTz).format(ms);
|
|
149
180
|
const meta = (...parts: string[]) => parts.filter(Boolean).join(' · ');
|
|
150
|
-
|
|
181
|
+
// the rule in effect, and a change from a date on still to come
|
|
182
|
+
const repeats = (e: CalEvent) => describeSeries(e, settings, now, lang, undefined, dateLocale);
|
|
151
183
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
152
184
|
/** an instant as the move form's date and time, in the event's zone */
|
|
153
185
|
const wallParts = (e: CalEvent, ms: number) => {
|
|
@@ -169,12 +201,21 @@ const requestTarget = (e: CalEvent, r: RequestRecord) => {
|
|
|
169
201
|
// …worded for its card: "Thu, Jan 10 · 6:00 – 7:00 PM → Wed, Jan 9 · 5:00 – 6:00 PM" while it waits, else the date as it is
|
|
170
202
|
const requestWhen = (r: RequestRecord) => {
|
|
171
203
|
const e = byId.get(r.eventId);
|
|
204
|
+
if (e && r.kind === 'from-date') {
|
|
205
|
+
// the date it changes from (while it's still there), then the new first date and its repeat
|
|
206
|
+
const f = r.data as FromDateRequestData;
|
|
207
|
+
const first = findOccurrence({ ...e, start: f.start, end: f.end, rrule: f.rrule, segments: [], movedDates: {}, cancelledDates: [], exdates: [] }, f.start, settings);
|
|
208
|
+
const from = r.state === 'pending' ? findOccurrence({ ...e, movedDates: {} }, r.key, settings) : null;
|
|
209
|
+
const to = [first ? when(first) : '', describeRepeat(f.rrule, f.start, undefined, lang)].filter(Boolean).join(' · ');
|
|
210
|
+
return from ? `${when(from)} → ${to}` : to;
|
|
211
|
+
}
|
|
172
212
|
const at = e && findOccurrence(e, r.key, settings);
|
|
173
213
|
if (!e || !at) return '';
|
|
174
214
|
const to = r.state === 'pending' ? requestTarget(e, r) : null;
|
|
175
215
|
return to ? `${when(at)} → ${when(to)}` : when(at);
|
|
176
216
|
};
|
|
177
217
|
const waitingMove = new Map(dateRequests.filter((r) => r.kind === 'move' && r.state === 'pending').map((r) => [`${r.eventId}|${r.key}`, r]));
|
|
218
|
+
const waitingFrom = new Set(dateRequests.filter((r) => r.kind === 'from-date' && r.state === 'pending').map((r) => `${r.eventId}|${r.key}`));
|
|
178
219
|
|
|
179
220
|
// requests: waiting ones first, newest first; shown as they'd be once approved
|
|
180
221
|
const requests = [
|
|
@@ -294,13 +335,16 @@ function moveValues(e: CalEvent, o: Occurrence) {
|
|
|
294
335
|
<ul class="pc-mem-dates">
|
|
295
336
|
{dates.map((o) => {
|
|
296
337
|
const off = e.cancelledDates.includes(o.key);
|
|
297
|
-
|
|
338
|
+
// a date cancelled with every date after it (a cancelled part) is the team's to restore
|
|
339
|
+
const open = e.rrule && !e.cancelled && o.start > now && (!o.cancelled || off);
|
|
298
340
|
const waiting = waitingMove.get(`${e.id}|${o.key}`);
|
|
299
341
|
const ownMove = o.moved && e.movedDates[o.key]?.by === user.id;
|
|
300
342
|
const clashHere = clash && clash.id === e.id && clash.key === o.key ? clash : null;
|
|
301
343
|
const target = waiting && requestTarget(e, waiting);
|
|
302
344
|
const v = moveValues(e, o);
|
|
303
|
-
const
|
|
345
|
+
const failed = Boolean(posted && posted.id === e.id && posted.key === o.key && error);
|
|
346
|
+
const reopen = failed && posted!.form.get('op') !== 'from-date';
|
|
347
|
+
const reopenFrom = failed && posted!.form.get('op') === 'from-date';
|
|
304
348
|
return (
|
|
305
349
|
<li class={o.cancelled ? 'is-cancelled' : undefined}>
|
|
306
350
|
<span>
|
|
@@ -308,6 +352,7 @@ function moveValues(e: CalEvent, o: Occurrence) {
|
|
|
308
352
|
{o.cancelled && <em>{w.cancelledDate}</em>}
|
|
309
353
|
{o.moved && !o.cancelled && <em class="is-moved" title={o.moved.note || undefined}>{fill(w.movedFrom, { when: when({ ...o, start: o.moved.from.start, end: o.moved.from.end }) })}</em>}
|
|
310
354
|
{waiting && <em class="is-pending">{(waiting.data as MoveRequestData).back ? w.moveBackRequested : fill(w.moveRequested, { when: target ? when(target) : '' })}</em>}
|
|
355
|
+
{waitingFrom.has(`${e.id}|${o.key}`) && <em class="is-pending">{fill(w.changeFromRequested, { when: day(o.start) })}</em>}
|
|
311
356
|
</span>
|
|
312
357
|
{open && (
|
|
313
358
|
<span class="pc-mem-date-acts">
|
|
@@ -377,6 +422,39 @@ function moveValues(e: CalEvent, o: Occurrence) {
|
|
|
377
422
|
</form>
|
|
378
423
|
</details>
|
|
379
424
|
)}
|
|
425
|
+
{open && !off && !clashHere && (
|
|
426
|
+
<details class="pc-mem-move" open={reopenFrom}>
|
|
427
|
+
<summary class="pc-mem-btn is-small" aria-label={fill(w.changeFromLabel, { title: ev.title, when: when(o) })}>{w.changeFrom}</summary>
|
|
428
|
+
<form method="post">
|
|
429
|
+
<input type="hidden" name="op" value="from-date" />
|
|
430
|
+
<input type="hidden" name="id" value={e.id} />
|
|
431
|
+
<input type="hidden" name="key" value={o.key} />
|
|
432
|
+
<p class="pc-mem-hint">{w.changeFromHint}</p>
|
|
433
|
+
<div class="pc-mem-row">
|
|
434
|
+
<label class="pc-bk-field"><span>{w.firstDate}</span><input type="date" name="date" required value={reopenFrom ? v.date : wallParts(e, o.start).date} /></label>
|
|
435
|
+
{!e.allDay && <label class="pc-bk-field"><span>{w.starts}</span><input type="time" name="startTime" required value={reopenFrom ? v.startTime : wallParts(e, o.start).time} /></label>}
|
|
436
|
+
{!e.allDay && <label class="pc-bk-field"><span>{w.ends}</span><input type="time" name="endTime" value={reopenFrom ? v.endTime : wallParts(e, o.end).time} /></label>}
|
|
437
|
+
</div>
|
|
438
|
+
<label class="pc-bk-field"><span>{w.form.repeats}</span>
|
|
439
|
+
<select name="repeat">
|
|
440
|
+
{([['same', w.sameRepeat], ['weekly', w.form.repeat.weekly], ['biweekly', w.form.repeat.biweekly], ['monthly', w.form.repeat.monthly], ['daily', w.form.repeat.daily], ['once', w.justThisDate]] as const)
|
|
441
|
+
.map(([value, label]) => <option value={value} selected={reopenFrom && posted!.form.get('repeat') === value}>{label}</option>)}
|
|
442
|
+
</select>
|
|
443
|
+
</label>
|
|
444
|
+
{rooms.length > 0 && (
|
|
445
|
+
<fieldset>
|
|
446
|
+
<legend>{w.rooms}</legend>
|
|
447
|
+
<input type="hidden" name="rooms" value="1" />
|
|
448
|
+
{rooms.map((r) => <label class="pc-bk-check"><input type="checkbox" name="roomIds" value={r.id} checked={(reopenFrom ? v.roomIds : o.roomIds).includes(r.id)} /> <span>{r.name}</span></label>)}
|
|
449
|
+
<p class="pc-mem-hint" style="flex-basis:100%">{w.roomsHint}</p>
|
|
450
|
+
</fieldset>
|
|
451
|
+
)}
|
|
452
|
+
<label class="pc-bk-field"><span>{w.publicNote} <em>{w.form.optional}</em></span><input name="note" maxlength="500" value={reopenFrom ? v.note : ''} /><small>{w.publicNoteHint}</small></label>
|
|
453
|
+
{!user.can('content.edit') && <label class="pc-bk-field"><span>{w.messageForTeam} <em>{w.form.optional}</em></span><textarea name="message" rows="2" maxlength="1000">{reopenFrom ? v.message : ''}</textarea></label>}
|
|
454
|
+
<div class="pc-mem-actions"><button class="pc-mem-btn is-small is-primary" type="submit">{user.can('content.edit') ? w.changeFrom.replace(/…$/, '') : w.sendForApproval}</button></div>
|
|
455
|
+
</form>
|
|
456
|
+
</details>
|
|
457
|
+
)}
|
|
380
458
|
</li>
|
|
381
459
|
);
|
|
382
460
|
})}
|