@rapidmx/web-client 0.2.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.
Files changed (168) hide show
  1. package/LICENSE +382 -0
  2. package/apps/admin/_404.tsx +9 -0
  3. package/apps/admin/_500.tsx +9 -0
  4. package/apps/admin/_layout.tsx +41 -0
  5. package/apps/admin/audit-log/index.tsx +187 -0
  6. package/apps/admin/branding/index.tsx +404 -0
  7. package/apps/admin/distribution-lists/[uid].tsx +70 -0
  8. package/apps/admin/distribution-lists/index.tsx +115 -0
  9. package/apps/admin/distribution-lists/new/index.tsx +114 -0
  10. package/apps/admin/domains/[uid].tsx +259 -0
  11. package/apps/admin/domains/index.tsx +125 -0
  12. package/apps/admin/domains/new/index.tsx +84 -0
  13. package/apps/admin/index.tsx +82 -0
  14. package/apps/admin/ingest-queue/index.tsx +116 -0
  15. package/apps/admin/mailboxes/[uid].tsx +126 -0
  16. package/apps/admin/mailboxes/new/index.tsx +291 -0
  17. package/apps/admin/quarantine/index.tsx +171 -0
  18. package/apps/admin/transport-rules/[uid].tsx +143 -0
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
  20. package/apps/admin/transport-rules/index.tsx +117 -0
  21. package/apps/admin/transport-rules/new/index.tsx +105 -0
  22. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
  23. package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
  24. package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
  25. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
  26. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
  27. package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
  29. package/apps/shared/components/calendar/EventModal.tsx +513 -0
  30. package/apps/shared/components/calendar/MonthView.tsx +139 -0
  31. package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
  32. package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
  33. package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
  34. package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
  35. package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
  36. package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
  37. package/apps/shared/components/contacts/ContactForm.tsx +314 -0
  38. package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
  40. package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
  41. package/apps/shared/components/layout/AppShell.tsx +165 -0
  42. package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
  44. package/apps/shared/components/layout/UserMenu.tsx +150 -0
  45. package/apps/shared/components/mail/ConversationList.tsx +61 -0
  46. package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
  48. package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
  49. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
  50. package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
  51. package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
  52. package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
  53. package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
  54. package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
  55. package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
  56. package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
  57. package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
  58. package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
  59. package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
  60. package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
  61. package/apps/shared/styles/app.css +265 -0
  62. package/apps/www/_404.tsx +9 -0
  63. package/apps/www/_500.tsx +9 -0
  64. package/apps/www/_layout.tsx +45 -0
  65. package/apps/www/calendar/index.tsx +407 -0
  66. package/apps/www/contacts/[uid].tsx +88 -0
  67. package/apps/www/contacts/index.tsx +436 -0
  68. package/apps/www/index.tsx +375 -0
  69. package/apps/www/messages/[uid].tsx +70 -0
  70. package/apps/www/settings/auto-reply/index.tsx +129 -0
  71. package/apps/www/settings/booking-types/[uid].tsx +300 -0
  72. package/apps/www/settings/booking-types/index.tsx +98 -0
  73. package/apps/www/settings/booking-types/new/index.tsx +212 -0
  74. package/apps/www/settings/filters/[uid].tsx +155 -0
  75. package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
  76. package/apps/www/settings/filters/index.tsx +99 -0
  77. package/apps/www/settings/filters/new/index.tsx +126 -0
  78. package/apps/www/settings/focused-inbox/index.tsx +148 -0
  79. package/apps/www/settings/read-receipts/index.tsx +125 -0
  80. package/apps/www/settings/signatures/[uid].tsx +167 -0
  81. package/apps/www/settings/signatures/index.tsx +85 -0
  82. package/apps/www/settings/signatures/new/index.tsx +129 -0
  83. package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
  84. package/apps/www/tasks/index.tsx +530 -0
  85. package/dist/apps/admin/_404.js +4 -0
  86. package/dist/apps/admin/_500.js +4 -0
  87. package/dist/apps/admin/_layout.js +10 -0
  88. package/dist/apps/admin/audit-log/index.js +66 -0
  89. package/dist/apps/admin/branding/index.js +126 -0
  90. package/dist/apps/admin/distribution-lists/[uid].js +34 -0
  91. package/dist/apps/admin/distribution-lists/index.js +31 -0
  92. package/dist/apps/admin/distribution-lists/new/index.js +51 -0
  93. package/dist/apps/admin/domains/[uid].js +102 -0
  94. package/dist/apps/admin/domains/index.js +31 -0
  95. package/dist/apps/admin/domains/new/index.js +41 -0
  96. package/dist/apps/admin/index.js +32 -0
  97. package/dist/apps/admin/ingest-queue/index.js +48 -0
  98. package/dist/apps/admin/mailboxes/[uid].js +59 -0
  99. package/dist/apps/admin/mailboxes/new/index.js +93 -0
  100. package/dist/apps/admin/quarantine/index.js +66 -0
  101. package/dist/apps/admin/transport-rules/[uid].js +87 -0
  102. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
  103. package/dist/apps/admin/transport-rules/index.js +32 -0
  104. package/dist/apps/admin/transport-rules/new/index.js +50 -0
  105. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
  106. package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
  107. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
  108. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
  109. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
  110. package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
  111. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
  112. package/dist/apps/shared/components/calendar/EventModal.js +167 -0
  113. package/dist/apps/shared/components/calendar/MonthView.js +38 -0
  114. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
  115. package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
  116. package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
  117. package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
  118. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
  119. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
  120. package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
  121. package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
  122. package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
  123. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
  124. package/dist/apps/shared/components/layout/AppShell.js +68 -0
  125. package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
  126. package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
  127. package/dist/apps/shared/components/layout/UserMenu.js +59 -0
  128. package/dist/apps/shared/components/mail/ConversationList.js +17 -0
  129. package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
  130. package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
  131. package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
  132. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
  133. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
  134. package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
  135. package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
  136. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
  137. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
  138. package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
  139. package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
  140. package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
  141. package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
  142. package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
  143. package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
  144. package/dist/apps/shared/styles/app.css +265 -0
  145. package/dist/apps/www/_404.js +4 -0
  146. package/dist/apps/www/_500.js +4 -0
  147. package/dist/apps/www/_layout.js +8 -0
  148. package/dist/apps/www/calendar/index.js +228 -0
  149. package/dist/apps/www/contacts/[uid].js +62 -0
  150. package/dist/apps/www/contacts/index.js +262 -0
  151. package/dist/apps/www/index.js +228 -0
  152. package/dist/apps/www/messages/[uid].js +47 -0
  153. package/dist/apps/www/settings/auto-reply/index.js +54 -0
  154. package/dist/apps/www/settings/booking-types/[uid].js +129 -0
  155. package/dist/apps/www/settings/booking-types/index.js +29 -0
  156. package/dist/apps/www/settings/booking-types/new/index.js +86 -0
  157. package/dist/apps/www/settings/filters/[uid].js +94 -0
  158. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
  159. package/dist/apps/www/settings/filters/index.js +30 -0
  160. package/dist/apps/www/settings/filters/new/index.js +61 -0
  161. package/dist/apps/www/settings/focused-inbox/index.js +69 -0
  162. package/dist/apps/www/settings/read-receipts/index.js +51 -0
  163. package/dist/apps/www/settings/signatures/[uid].js +96 -0
  164. package/dist/apps/www/settings/signatures/index.js +34 -0
  165. package/dist/apps/www/settings/signatures/new/index.js +63 -0
  166. package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
  167. package/dist/apps/www/tasks/index.js +280 -0
  168. package/package.json +87 -0
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ function formatBytes(bytes) {
3
+ if (bytes >= 1000000000)
4
+ return `${(bytes / 1000000000).toFixed(1)} GB`;
5
+ if (bytes >= 1000000)
6
+ return `${(bytes / 1000000).toFixed(1)} MB`;
7
+ if (bytes >= 1000)
8
+ return `${(bytes / 1000).toFixed(1)} KB`;
9
+ return `${bytes} B`;
10
+ }
11
+ export default function MailboxTable({ mailboxes }) {
12
+ if (mailboxes.length === 0) {
13
+ return _jsx("p", { className: "text-sm text-text-muted", children: "No mailboxes found." });
14
+ }
15
+ return (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Address" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Display name" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Owner" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Quota used" }), _jsx("th", { className: "border-b border-border" })] }) }), _jsx("tbody", { children: mailboxes.map((mailbox) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: mailbox.primarySmtpAddress }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: mailbox.displayName }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: mailbox.ownerUserUid ?? (_jsx("span", { className: "inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Shared" })) }), _jsxs("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle text-right", children: _jsx("a", { href: `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`, className: "text-primary-dark font-medium hover:underline", children: "View" }) })] }, mailbox.uid))) })] }) }));
16
+ }
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { updateMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
12
+ const SELECT_CLASS = "text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
13
+ /**
14
+ * Lets an admin view and edit a resource mailbox's booking settings — same load/edit/PUT/reload
15
+ * interaction shape as `ShareAccessCard`, adapted the same way `MemberListCard` already adapted it:
16
+ * these seven fields live directly on the `Mailbox` the parent detail page already loaded (not a
17
+ * separate resource fetched by its own endpoint, unlike a mailbox's ACL), so this receives the
18
+ * already-loaded `mailbox` and pushes the `PUT` response back up rather than fetching independently.
19
+ * Only rendered by the detail page when `mailbox.isResource` is already `true` — a mailbox becomes a
20
+ * resource at creation time (`apps/admin/mailboxes/new`), not retroactively from here.
21
+ */
22
+ export default function ResourceSettingsCard({ mailbox, onUpdate }) {
23
+ const [resourceType, setResourceType] = useState(mailbox.resourceType ?? "room");
24
+ const [resourceCapacity, setResourceCapacity] = useState(mailbox.resourceCapacity?.toString() ?? "");
25
+ const [autoAcceptBookings, setAutoAcceptBookings] = useState(!!mailbox.autoAcceptBookings);
26
+ const [allowConflicts, setAllowConflicts] = useState(!!mailbox.allowConflicts);
27
+ const [bookingWindowDays, setBookingWindowDays] = useState(mailbox.bookingWindowDays?.toString() ?? "");
28
+ const [maxDurationMinutes, setMaxDurationMinutes] = useState(mailbox.maxDurationMinutes?.toString() ?? "");
29
+ const [error, setError] = useState(null);
30
+ const [saving, setSaving] = useState(false);
31
+ const [saved, setSaved] = useState(false);
32
+ async function handleSubmit(e) {
33
+ e.preventDefault();
34
+ setError(null);
35
+ setSaved(false);
36
+ setSaving(true);
37
+ try {
38
+ const updated = await updateMailbox({
39
+ uid: mailbox.uid,
40
+ version: mailbox.version,
41
+ resourceType,
42
+ resourceCapacity: resourceCapacity ? Number(resourceCapacity) : undefined,
43
+ autoAcceptBookings,
44
+ allowConflicts,
45
+ bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) : undefined,
46
+ maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) : undefined,
47
+ });
48
+ onUpdate(updated);
49
+ setSaved(true);
50
+ }
51
+ catch (err) {
52
+ setError(err instanceof ApiRequestError ? err.message : "Could not save resource settings.");
53
+ }
54
+ finally {
55
+ setSaving(false);
56
+ }
57
+ }
58
+ return (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-4", children: "Resource settings" }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Resource type" }), _jsxs("select", { "aria-label": "Resource type", className: SELECT_CLASS, value: resourceType, onChange: (e) => setResourceType(e.target.value), children: [_jsx("option", { value: "room", children: "Room" }), _jsx("option", { value: "equipment", children: "Equipment" })] })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Capacity (optional)" }), _jsx("input", { type: "number", min: 0, step: 1, "aria-label": "Capacity", className: INPUT_CLASS, value: resourceCapacity, onChange: (e) => setResourceCapacity(e.target.value), placeholder: "Informational only" })] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: autoAcceptBookings, onChange: (e) => setAutoAcceptBookings(e.target.checked) }), "Automatically accept booking requests"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: allowConflicts, onChange: (e) => setAllowConflicts(e.target.checked) }), "Allow conflicting bookings (skip conflict checking entirely)"] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Booking window, in days (optional)" }), _jsx("input", { type: "number", min: 0, step: 1, "aria-label": "Booking window, in days", className: INPUT_CLASS, value: bookingWindowDays, onChange: (e) => setBookingWindowDays(e.target.value), placeholder: "No limit" })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Maximum duration, in minutes (optional)" }), _jsx("input", { type: "number", min: 0, step: 1, "aria-label": "Maximum duration, in minutes", className: INPUT_CLASS, value: maxDurationMinutes, onChange: (e) => setMaxDurationMinutes(e.target.value), placeholder: "No limit" })] }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save resource settings" }) })] })] }));
59
+ }
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { getMailboxAcl, grantMailboxAccess, revokeMailboxAccess } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ const DEFAULT_DELEGATE_ACTIONS = ["read", "list", "count", "exists"];
12
+ /**
13
+ * Lets an admin (or, eventually, the mailbox owner) view and edit a mailbox's delegate access — the
14
+ * mechanism behind Exchange-style shared mailboxes. `records` on a mailbox's own ACL doubles as both the
15
+ * owner's grant (if any) and every delegate's — see `BaseMailboxRoute`'s doc comment in `@rapidmx/restapi`.
16
+ */
17
+ export default function ShareAccessCard({ mailboxUid }) {
18
+ const [acl, setAcl] = useState(null);
19
+ const [loading, setLoading] = useState(true);
20
+ const [error, setError] = useState(null);
21
+ const [newUserUid, setNewUserUid] = useState("");
22
+ const [saving, setSaving] = useState(false);
23
+ function reload() {
24
+ setLoading(true);
25
+ setError(null);
26
+ getMailboxAcl(mailboxUid)
27
+ .then(setAcl)
28
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load share settings."))
29
+ .finally(() => setLoading(false));
30
+ }
31
+ useEffect(reload, [mailboxUid]);
32
+ async function handleGrant(e) {
33
+ e.preventDefault();
34
+ const userUid = newUserUid.trim();
35
+ if (!userUid) {
36
+ return;
37
+ }
38
+ setSaving(true);
39
+ setError(null);
40
+ try {
41
+ await grantMailboxAccess(mailboxUid, userUid, DEFAULT_DELEGATE_ACTIONS);
42
+ setNewUserUid("");
43
+ reload();
44
+ }
45
+ catch (err) {
46
+ setError(err instanceof ApiRequestError ? err.message : "Could not grant access.");
47
+ }
48
+ finally {
49
+ setSaving(false);
50
+ }
51
+ }
52
+ async function handleRevoke(userOrRoleId) {
53
+ setSaving(true);
54
+ setError(null);
55
+ try {
56
+ await revokeMailboxAccess(mailboxUid, userOrRoleId);
57
+ reload();
58
+ }
59
+ catch (err) {
60
+ setError(err instanceof ApiRequestError ? err.message : "Could not revoke access.");
61
+ }
62
+ finally {
63
+ setSaving(false);
64
+ }
65
+ }
66
+ return (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Shared access" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "Grants read access to this mailbox for another user, independent of ownership." }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs("ul", { className: "flex flex-col gap-2 mb-4", children: [(acl?.records ?? []).length === 0 && (_jsx("li", { className: "text-sm text-text-muted", children: "No grants on this mailbox yet." })), acl?.records.map((record) => (_jsxs("li", { className: "flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium", children: record.userOrRoleId }), _jsx("div", { className: "text-xs text-text-muted", children: record.actions.join(", ") })] }), _jsx("button", { type: "button", onClick: () => handleRevoke(record.userOrRoleId), disabled: saving, className: "text-sm text-danger hover:underline disabled:opacity-55", children: "Revoke" })] }, record.userOrRoleId)))] })), _jsxs("form", { onSubmit: handleGrant, className: "flex gap-2", children: [_jsx("input", { type: "text", value: newUserUid, onChange: (e) => setNewUserUid(e.target.value), placeholder: "User uid to grant access to", className: "flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary" }), _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Grant" })] })] }));
67
+ }
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
8
+ const DAY_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
9
+ const INPUT_CLASS = "text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
10
+ function minutesToTimeValue(minutes) {
11
+ const h = Math.floor(minutes / 60)
12
+ .toString()
13
+ .padStart(2, "0");
14
+ const m = (minutes % 60).toString().padStart(2, "0");
15
+ return `${h}:${m}`;
16
+ }
17
+ function timeValueToMinutes(value) {
18
+ const [h, m] = value.split(":").map(Number);
19
+ return h * 60 + m;
20
+ }
21
+ /**
22
+ * Adds/removes `BookingType.availability` weekly windows — deliberately does not cover `dateOverrides`
23
+ * (per-date blackouts/exceptions), a separate, less commonly needed escape hatch left for a future pass;
24
+ * `BookingUtils.generateCandidateSlots()` already handles an empty `dateOverrides` array gracefully.
25
+ */
26
+ export default function AvailabilityEditor({ value, onChange }) {
27
+ const [dayOfWeek, setDayOfWeek] = useState(1);
28
+ const [startTime, setStartTime] = useState("09:00");
29
+ const [endTime, setEndTime] = useState("17:00");
30
+ const [error, setError] = useState(null);
31
+ function handleAdd() {
32
+ const startMinute = timeValueToMinutes(startTime);
33
+ const endMinute = timeValueToMinutes(endTime);
34
+ if (startMinute >= endMinute) {
35
+ setError("Start time must be before end time.");
36
+ return;
37
+ }
38
+ setError(null);
39
+ onChange([...value, { dayOfWeek, startMinute, endMinute }]);
40
+ }
41
+ function handleRemove(index) {
42
+ onChange(value.filter((_, i) => i !== index));
43
+ }
44
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [error && _jsx("p", { className: "text-sm text-danger", children: error }), value.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No availability windows yet \u2014 add one below." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: value.map((window, index) => (_jsxs("li", { className: "flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm text-sm", children: [_jsxs("span", { children: [DAY_LABELS[window.dayOfWeek], " ", minutesToTimeValue(window.startMinute), "\u2013", minutesToTimeValue(window.endMinute)] }), _jsx("button", { type: "button", onClick: () => handleRemove(index), className: "text-sm text-danger hover:underline", children: "Remove" })] }, index))) })), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("select", { "aria-label": "Day of week", value: dayOfWeek, onChange: (e) => setDayOfWeek(Number(e.target.value)), className: INPUT_CLASS, children: DAY_LABELS.map((label, i) => (_jsx("option", { value: i, children: label }, label))) }), _jsx("input", { type: "time", "aria-label": "Start time", value: startTime, onChange: (e) => setStartTime(e.target.value), className: INPUT_CLASS }), _jsx("span", { className: "text-sm text-text-muted", children: "to" }), _jsx("input", { type: "time", "aria-label": "End time", value: endTime, onChange: (e) => setEndTime(e.target.value), className: INPUT_CLASS }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: handleAdd, children: "Add window" })] })] }));
45
+ }
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import { CALENDAR_COLOR_PALETTE, colorForFolder } from "@rapidmx/react-shared/calendar/calendarColors.js";
8
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
+ /**
10
+ * Calendar's left-sidebar checklist of "My calendars" — one row per `calendar`-type folder, a color
11
+ * swatch matching `colorForFolder`, and a checkbox controlling whether that calendar's events are
12
+ * currently shown (the checked set is owned by `apps/www/calendar/index.tsx`, since it drives which
13
+ * folders get fanned out to on every reload). "+ Add calendar" is a real `createFolder({type:
14
+ * "calendar", ...})` call — see the Phase 4 plan's own note that folder creation has no type
15
+ * restriction server-side, so this needed no new backend route.
16
+ */
17
+ export default function CalendarListSidebar({ calendars, checkedFolderUids, onToggle, onAddCalendar }) {
18
+ const [adding, setAdding] = useState(false);
19
+ const [name, setName] = useState("");
20
+ const [color, setColor] = useState(CALENDAR_COLOR_PALETTE[0]);
21
+ const [error, setError] = useState(null);
22
+ const [saving, setSaving] = useState(false);
23
+ async function handleAdd(e) {
24
+ e.preventDefault();
25
+ if (!name.trim()) {
26
+ return;
27
+ }
28
+ setError(null);
29
+ setSaving(true);
30
+ try {
31
+ await onAddCalendar(name.trim(), color);
32
+ setName("");
33
+ setColor(CALENDAR_COLOR_PALETTE[0]);
34
+ setAdding(false);
35
+ }
36
+ catch {
37
+ setError("Could not create this calendar.");
38
+ }
39
+ finally {
40
+ setSaving(false);
41
+ }
42
+ }
43
+ return (_jsxs("nav", { "aria-label": "My calendars", className: "p-3 border-t border-border", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "My calendars" }), _jsx("button", { type: "button", onClick: () => setAdding(true), "aria-label": "Add calendar", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), error && (_jsx("div", { className: "mb-1", children: _jsx(Alert, { children: error }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: calendars.map((folder) => (_jsxs("label", { className: "flex items-center gap-2 text-sm px-1 py-1 rounded-sm hover:bg-surface-alt cursor-pointer", children: [_jsx("input", { type: "checkbox", checked: checkedFolderUids.has(folder.uid), onChange: () => onToggle(folder.uid), style: { accentColor: colorForFolder(folder) } }), _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: colorForFolder(folder) } }), _jsx("span", { className: "truncate", children: folder.name })] }, folder.uid))) }), adding && (_jsxs("form", { onSubmit: handleAdd, className: "flex flex-col gap-1.5 mt-2", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New calendar name", value: name, onChange: (e) => setName(e.target.value), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("div", { className: "flex items-center gap-1", role: "radiogroup", "aria-label": "Calendar color", children: CALENDAR_COLOR_PALETTE.map((swatch) => (_jsx("button", { type: "button", "aria-label": `Color ${swatch}`, "aria-pressed": color === swatch, onClick: () => setColor(swatch), className: ["w-4 h-4 rounded-full shrink-0", color === swatch ? "ring-2 ring-offset-1 ring-text" : ""].join(" "), style: { backgroundColor: swatch } }, swatch))) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "submit", disabled: saving, className: "text-xs font-semibold text-primary-dark", children: "Add" }), _jsx("button", { type: "button", onClick: () => setAdding(false), className: "text-xs text-text-muted", children: "Cancel" })] })] }))] }));
44
+ }
@@ -0,0 +1,167 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useRef, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { createCalendarEvent, respondToEvent, updateCalendarEvent, } from "@rapidmx/react-shared/calendar/calendarApi.js";
9
+ import { deleteEventOccurrence, deleteEventSeries, detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
10
+ import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
11
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
12
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
14
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
15
+ import RecurrenceEditor from "./RecurrenceEditor.js";
16
+ import ResourcePicker from "./ResourcePicker.js";
17
+ const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
18
+ const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
19
+ const ATTENDEE_ROLES = ["required", "optional", "resource"];
20
+ const BUSY_STATUSES = ["busy", "free", "tentative", "oof"];
21
+ const BUSY_STATUS_LABEL = { busy: "Busy", free: "Free", tentative: "Tentative", oof: "Out of office" };
22
+ const RESPONSE_STATUS_LABEL = {
23
+ needsAction: "Awaiting response",
24
+ accepted: "Accepted",
25
+ declined: "Declined",
26
+ tentative: "Tentative",
27
+ };
28
+ /**
29
+ * Create/view/edit/delete for a single calendar event. Editing or deleting a recurring event's
30
+ * occurrence offers a choice between "this event" and "the entire series" (see
31
+ * `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
32
+ */
33
+ export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, organizerAddress, occurrence, initialStart, initialEnd, onSaved, onDeleted, }) {
34
+ const [targetFolderUid, setTargetFolderUid] = useState(folderUid);
35
+ const [title, setTitle] = useState(occurrence?.title ?? "");
36
+ const [location, setLocation] = useState(occurrence?.location ?? "");
37
+ const [start, setStart] = useState(toDatetimeLocal(occurrence?.startDate ?? initialStart?.toISOString() ?? new Date().toISOString()));
38
+ const [end, setEnd] = useState(toDatetimeLocal(occurrence?.endDate ?? initialEnd?.toISOString() ?? new Date(Date.now() + 30 * 60000).toISOString()));
39
+ const [allDay, setAllDay] = useState(occurrence?.allDay ?? false);
40
+ const [timezone] = useState(occurrence?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone);
41
+ const [attendees, setAttendees] = useState(occurrence?.attendees ?? []);
42
+ const [recurrenceRule, setRecurrenceRule] = useState(occurrence?.recurrenceRule ?? null);
43
+ const [reminderMinutes, setReminderMinutes] = useState(occurrence?.reminderMinutesBeforeStart?.toString() ?? "");
44
+ const [busyStatus, setBusyStatus] = useState(occurrence?.busyStatus ?? "busy");
45
+ const [autoReplyEnabled, setAutoReplyEnabled] = useState(occurrence?.autoReplyEnabled ?? false);
46
+ const [autoReplyMessage, setAutoReplyMessage] = useState(occurrence?.autoReplyMessage ?? "");
47
+ const [editScope, setEditScope] = useState("occurrence");
48
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
49
+ const [error, setError] = useState(null);
50
+ const [saving, setSaving] = useState(false);
51
+ const [responding, setResponding] = useState(false);
52
+ const [resourcePickerOpen, setResourcePickerOpen] = useState(false);
53
+ const addResourceButtonRef = useRef(null);
54
+ // The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize —
55
+ // `organizerAddress` is always this mailbox's own primary SMTP address (see `EventModalProps`), so
56
+ // finding it among `attendees` (and not as that attendee's own organizer flag) identifies exactly
57
+ // that case. `@rapidmx/restapi`'s `respond()` route has no occurrence-vs-series scope of its own
58
+ // (see `calendarApi.ts`'s `respondToEvent` doc comment) — it always acts on `occurrence.uid` as a
59
+ // single event document, so this deliberately does not consult `editScope`.
60
+ const myAttendeeIndex = occurrence ? attendees.findIndex((a) => a.address.toLowerCase() === organizerAddress.toLowerCase()) : -1;
61
+ const canRespond = myAttendeeIndex !== -1 && !attendees[myAttendeeIndex].isOrganizer;
62
+ function updateAttendee(index, patch) {
63
+ setAttendees((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)));
64
+ }
65
+ function removeAttendee(index) {
66
+ setAttendees((prev) => prev.filter((_, i) => i !== index));
67
+ }
68
+ function addResource(mailbox) {
69
+ setAttendees((prev) => [
70
+ ...prev,
71
+ { address: mailbox.primarySmtpAddress, displayName: mailbox.displayName, role: "resource", responseStatus: "needsAction", isOrganizer: false },
72
+ ]);
73
+ setResourcePickerOpen(false);
74
+ }
75
+ async function handleSubmit(e) {
76
+ e.preventDefault();
77
+ setError(null);
78
+ if (!title.trim()) {
79
+ setError("A title is required.");
80
+ return;
81
+ }
82
+ if (new Date(end) <= new Date(start)) {
83
+ setError("The end time must be after the start time.");
84
+ return;
85
+ }
86
+ const fields = {
87
+ title: title.trim(),
88
+ location: location.trim() || undefined,
89
+ startDate: new Date(start).toISOString(),
90
+ endDate: new Date(end).toISOString(),
91
+ allDay,
92
+ timezone,
93
+ organizer: { address: organizerAddress, type: "to" },
94
+ attendees,
95
+ recurrenceRule: recurrenceRule ?? undefined,
96
+ reminderMinutesBeforeStart: reminderMinutes.trim() ? Number(reminderMinutes) : undefined,
97
+ busyStatus,
98
+ autoReplyEnabled,
99
+ autoReplyMessage: autoReplyEnabled ? autoReplyMessage : undefined,
100
+ };
101
+ setSaving(true);
102
+ try {
103
+ if (!occurrence) {
104
+ await createCalendarEvent({ mailboxUid, folderUid: targetFolderUid, ...fields });
105
+ }
106
+ else if (occurrence.isRecurringOccurrence && editScope === "occurrence") {
107
+ await detachOccurrence(occurrence, fields);
108
+ }
109
+ else if (occurrence.isRecurringOccurrence) {
110
+ await saveEventSeries(occurrence, fields);
111
+ }
112
+ else {
113
+ await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields });
114
+ }
115
+ onSaved();
116
+ }
117
+ catch (err) {
118
+ setError(err instanceof ApiRequestError ? err.message : "Could not save this event.");
119
+ }
120
+ finally {
121
+ setSaving(false);
122
+ }
123
+ }
124
+ // Only ever invoked from the Delete button(s) rendered inside the `{occurrence && (...)}` block
125
+ // below, so `occurrence` is always non-null here — no defensive null check needed.
126
+ async function handleDelete(scope) {
127
+ setError(null);
128
+ setSaving(true);
129
+ try {
130
+ if (occurrence.isRecurringOccurrence && scope === "occurrence") {
131
+ await deleteEventOccurrence(occurrence);
132
+ }
133
+ else {
134
+ await deleteEventSeries(occurrence);
135
+ }
136
+ onDeleted();
137
+ }
138
+ catch (err) {
139
+ setError(err instanceof ApiRequestError ? err.message : "Could not delete this event.");
140
+ setSaving(false);
141
+ }
142
+ }
143
+ // Only rendered inside the `{canRespond && (...)}` block below, which already requires `occurrence`
144
+ // to be non-null (see `canRespond`'s own definition above) — same established pattern as
145
+ // `handleDelete`'s non-null assertion.
146
+ async function handleRespond(responseStatus) {
147
+ setError(null);
148
+ setResponding(true);
149
+ try {
150
+ await respondToEvent(occurrence.uid, responseStatus);
151
+ // A decline soft-deletes the mailbox's own copy server-side — treat it the same as a
152
+ // delete rather than a save so the calendar view drops it immediately.
153
+ if (responseStatus === "declined") {
154
+ onDeleted();
155
+ }
156
+ else {
157
+ onSaved();
158
+ }
159
+ }
160
+ catch (err) {
161
+ setError(err instanceof ApiRequestError ? err.message : "Could not send your response.");
162
+ setResponding(false);
163
+ }
164
+ }
165
+ return (_jsx(Modal, { open: open, onClose: onClose, title: occurrence ? "Edit event" : "New event", children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-1", children: [error && _jsx(Alert, { children: error }), !occurrence && calendars && calendars.length > 1 && (_jsx(FormField, { label: "Calendar", htmlFor: "event-calendar", children: _jsx("select", { id: "event-calendar", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: calendars.map((cal) => (_jsx("option", { value: cal.uid, children: cal.name }, cal.uid))) }) })), _jsx(FormField, { label: "Title", htmlFor: "event-title", children: _jsx("input", { id: "event-title", type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }) }), _jsx(FormField, { label: "Location", htmlFor: "event-location", children: _jsx("input", { id: "event-location", type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm font-medium mb-3", children: [_jsx("input", { type: "checkbox", checked: allDay, onChange: (e) => setAllDay(e.target.checked) }), "All day"] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Start", htmlFor: "event-start", children: _jsx("input", { id: "event-start", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? start.slice(0, 10) : start, onChange: (e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value) }) }), _jsx(FormField, { label: "End", htmlFor: "event-end", children: _jsx("input", { id: "event-end", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? end.slice(0, 10) : end, onChange: (e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value) }) })] }), _jsx(FormField, { label: "Attendees", htmlFor: "event-attendees", children: _jsxs("div", { id: "event-attendees", className: "flex flex-col gap-2", children: [attendees.map((attendee, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: attendee.address, onChange: (e) => updateAttendee(i, { address: e.target.value }), "aria-label": `Attendee email ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center", children: RESPONSE_STATUS_LABEL[attendee.responseStatus] }), _jsx("button", { type: "button", onClick: () => removeAttendee(i), className: "text-sm text-danger px-2", "aria-label": `Remove attendee ${i + 1}`, children: "\u00D7" })] }, i))), _jsxs("div", { className: "flex gap-3 items-center", children: [_jsx("button", { type: "button", onClick: () => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add attendee" }), _jsx("button", { ref: addResourceButtonRef, type: "button", onClick: () => setResourcePickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" })] }), resourcePickerOpen && (_jsx(ResourcePicker, { anchorRef: addResourceButtonRef, onClose: () => setResourcePickerOpen(false), onSelect: addResource, excludeAddresses: attendees.map((a) => a.address.toLowerCase()) }))] }) }), canRespond && (_jsxs("div", { className: "flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsx("span", { className: "text-sm font-medium", children: "Your response" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("accepted"), children: "Accept" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("tentative"), children: "Tentative" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: responding, onClick: () => handleRespond("declined"), children: "Decline" })] })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Other attendees\u2019 responses may take a few minutes to update." })] })), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Busy status", htmlFor: "event-busyStatus", children: _jsx("select", { id: "event-busyStatus", className: INPUT_CLASS, value: busyStatus, onChange: (e) => setBusyStatus(e.target.value), children: BUSY_STATUSES.map((status) => (_jsx("option", { value: status, children: BUSY_STATUS_LABEL[status] }, status))) }) }), _jsx(FormField, { label: "Reminder (minutes before)", htmlFor: "event-reminder", children: _jsx("input", { id: "event-reminder", type: "number", min: 0, className: INPUT_CLASS, value: reminderMinutes, onChange: (e) => setReminderMinutes(e.target.value), placeholder: "None" }) })] }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: autoReplyEnabled, onChange: (e) => setAutoReplyEnabled(e.target.checked) }), "Send an automatic reply while this event is happening"] }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "Applies in addition to your mailbox\u2019s own Automatic Replies setting (see Settings) \u2014 this event\u2019s message takes over for its own start/end window." }), autoReplyEnabled && (_jsx("textarea", { "aria-label": "Automatic reply message", className: `${INPUT_CLASS} mt-2`, rows: 3, value: autoReplyMessage, onChange: (e) => setAutoReplyMessage(e.target.value), placeholder: "I'm out of office and back on..." }))] }), _jsx(FormField, { label: "Recurrence", htmlFor: "event-recurrence", children: _jsx(RecurrenceEditor, { value: recurrenceRule, onChange: setRecurrenceRule }) }), occurrence?.isRecurringOccurrence && (_jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm mb-3", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Apply changes to" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "occurrence", onChange: () => setEditScope("occurrence") }), "This event only"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "series", onChange: () => setEditScope("series") }), "The entire series"] })] })), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), occurrence &&
166
+ (!occurrence.isRecurringOccurrence ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete" })) : !confirmingDelete ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => setConfirmingDelete(true), children: "Delete" })) : (_jsxs("div", { className: "flex gap-2 ml-auto", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("occurrence"), children: "Delete this event" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete series" })] })))] })] }) }));
167
+ }
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useDraggable, useDroppable } from "@dnd-kit/core";
3
+ import { eachDayOfInterval, endOfMonth, endOfWeek, format, isSameDay, isSameMonth, isToday, startOfMonth, startOfWeek } from "date-fns";
4
+ import { dayDropId, eventDragId } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
5
+ const MAX_CHIPS_PER_DAY = 3;
6
+ /** A real 6-week month grid (Mon-start), matching Outlook/Gmail's month view. Multi-day/all-day
7
+ * events are shown only on their start day — a deliberate v1 simplification, not a bug. */
8
+ export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent }) {
9
+ const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 1 });
10
+ const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 1 });
11
+ const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
12
+ return (_jsx("div", { className: "flex-1 grid grid-cols-7 grid-rows-6 min-h-0", role: "grid", "aria-label": "Month", children: days.map((day) => (_jsx(DayCell, { day: day, inCurrentMonth: isSameMonth(day, viewDate), occurrences: occurrences.filter((occ) => isSameDay(new Date(occ.startDate), day)), folderColors: folderColors, onSelectDay: onSelectDay, onSelectEvent: onSelectEvent }, day.toISOString()))) }));
13
+ }
14
+ function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay, onSelectEvent }) {
15
+ const { setNodeRef, isOver } = useDroppable({ id: dayDropId(day) });
16
+ const visible = occurrences.slice(0, MAX_CHIPS_PER_DAY);
17
+ const overflowCount = occurrences.length - visible.length;
18
+ return (_jsxs("div", { ref: setNodeRef, className: [
19
+ "border-b border-r border-border p-1 flex flex-col gap-0.5 min-h-0 overflow-hidden",
20
+ isOver ? "bg-primary/5" : "",
21
+ inCurrentMonth ? "bg-surface" : "bg-surface-alt",
22
+ ].join(" "), children: [_jsx("button", { type: "button", onClick: () => onSelectDay(day), className: [
23
+ "self-start text-xs font-semibold w-6 h-6 rounded-full flex items-center justify-center shrink-0",
24
+ isToday(day) ? "bg-primary text-white" : inCurrentMonth ? "text-text" : "text-text-muted",
25
+ ].join(" "), children: format(day, "d") }), _jsxs("div", { className: "flex-1 flex flex-col gap-0.5 min-h-0 overflow-hidden", children: [visible.map((occurrence) => (_jsx(EventChip, { occurrence: occurrence, color: folderColors[occurrence.folderUid], onSelect: onSelectEvent }, occurrence.occurrenceKey))), overflowCount > 0 && (_jsxs("button", { type: "button", onClick: () => onSelectDay(day), className: "text-xs text-text-muted text-left hover:underline shrink-0", children: ["+", overflowCount, " more"] }))] })] }));
26
+ }
27
+ function EventChip({ occurrence, color, onSelect, }) {
28
+ const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
29
+ const isFree = occurrence.busyStatus === "free";
30
+ return (_jsxs("button", { ref: setNodeRef, type: "button", onClick: () => onSelect(occurrence), style: {
31
+ ...(transform ? { transform: `translate(${transform.x}px, ${transform.y}px)`, zIndex: 10 } : undefined),
32
+ ...(isFree ? undefined : { backgroundColor: color, color: "#fff" }),
33
+ }, className: [
34
+ "text-xs text-left truncate rounded-sm px-1.5 py-0.5 shrink-0",
35
+ isFree ? "bg-surface-alt text-text-muted" : "",
36
+ isDragging ? "opacity-50" : "",
37
+ ].join(" "), ...listeners, ...attributes, children: [occurrence.allDay ? "" : `${format(new Date(occurrence.startDate), "h:mma")} `, occurrence.title] }));
38
+ }
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { WEEKDAY_CODES, WEEKDAY_LABELS, describeRecurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
3
+ const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
4
+ const INPUT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
5
+ const FREQ_LABEL = {
6
+ daily: { unit: "day", unitPlural: "days" },
7
+ weekly: { unit: "week", unitPlural: "weeks" },
8
+ monthly: { unit: "month", unitPlural: "months" },
9
+ yearly: { unit: "year", unitPlural: "years" },
10
+ };
11
+ function endConditionOf(rule) {
12
+ if (rule.count)
13
+ return "count";
14
+ if (rule.until)
15
+ return "until";
16
+ return "never";
17
+ }
18
+ /**
19
+ * A recurring-event editor: frequency + interval, a weekday picker (weekly only), and an end
20
+ * condition (never / after N occurrences / on a date) — everything `RecurrenceRule` can express. The
21
+ * live "every ... until/for ..." summary comes from `describeRecurrence()` (built on `rrule`'s own
22
+ * `.toText()`), so it never drifts out of sync with what will actually be submitted.
23
+ */
24
+ export default function RecurrenceEditor({ value, onChange }) {
25
+ function handleEnable(enabled) {
26
+ onChange(enabled ? { freq: "weekly", interval: 1, byDay: ["MO"], exceptions: [] } : null);
27
+ }
28
+ // `update`/`toggleDay` are only ever invoked from handlers rendered inside the `{value && (...)}`
29
+ // block below, so `value` is always non-null here — no defensive null check needed.
30
+ function update(patch) {
31
+ onChange({ ...value, ...patch });
32
+ }
33
+ function toggleDay(day) {
34
+ const current = value.byDay ?? [];
35
+ const next = current.includes(day) ? current.filter((d) => d !== day) : [...current, day];
36
+ update({ byDay: next });
37
+ }
38
+ function handleEndCondition(condition) {
39
+ if (condition === "never") {
40
+ update({ count: undefined, until: undefined });
41
+ }
42
+ else if (condition === "count") {
43
+ update({ count: 10, until: undefined });
44
+ }
45
+ else {
46
+ update({ until: new Date().toISOString(), count: undefined });
47
+ }
48
+ }
49
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: value !== null, onChange: (e) => handleEnable(e.target.checked) }), "Repeats"] }), value && (_jsxs("div", { className: "flex flex-col gap-3 pl-6 border-l-2 border-border", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx("span", { children: "Every" }), _jsx("input", { type: "number", min: 1, className: `${INPUT_CLASS} w-16`, value: value.interval, onChange: (e) => update({ interval: Number(e.target.value) }), "aria-label": "Recurrence interval" }), _jsxs("select", { className: SELECT_CLASS, value: value.freq, onChange: (e) => update({ freq: e.target.value }), "aria-label": "Recurrence frequency", children: [_jsx("option", { value: "daily", children: value.interval === 1 ? FREQ_LABEL.daily.unit : FREQ_LABEL.daily.unitPlural }), _jsx("option", { value: "weekly", children: value.interval === 1 ? FREQ_LABEL.weekly.unit : FREQ_LABEL.weekly.unitPlural }), _jsx("option", { value: "monthly", children: value.interval === 1 ? FREQ_LABEL.monthly.unit : FREQ_LABEL.monthly.unitPlural }), _jsx("option", { value: "yearly", children: value.interval === 1 ? FREQ_LABEL.yearly.unit : FREQ_LABEL.yearly.unitPlural })] })] }), value.freq === "weekly" && (_jsx("div", { className: "flex gap-1", children: WEEKDAY_CODES.map((day) => (_jsx("button", { type: "button", onClick: () => toggleDay(day), "aria-pressed": (value.byDay ?? []).includes(day), "aria-label": WEEKDAY_LABELS[day], className: [
50
+ "w-9 h-9 rounded-full text-xs font-semibold border",
51
+ (value.byDay ?? []).includes(day)
52
+ ? "bg-primary text-white border-primary"
53
+ : "bg-surface text-text-muted border-border hover:border-primary",
54
+ ].join(" "), children: _jsx("span", { "aria-hidden": "true", children: WEEKDAY_LABELS[day][0] }) }, day))) })), _jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Ends" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "recurrence-end", checked: endConditionOf(value) === "never", onChange: () => handleEndCondition("never") }), "Never"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "recurrence-end", "aria-label": "After", checked: endConditionOf(value) === "count", onChange: () => handleEndCondition("count") }), "After", _jsx("input", { type: "number", min: 1, className: `${INPUT_CLASS} w-16`, value: value.count ?? 10, disabled: endConditionOf(value) !== "count", onChange: (e) => update({ count: Number(e.target.value) }), "aria-label": "Number of occurrences" }), "occurrences"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "recurrence-end", "aria-label": "On", checked: endConditionOf(value) === "until", onChange: () => handleEndCondition("until") }), "On", _jsx("input", { type: "date", className: INPUT_CLASS, value: value.until ? value.until.slice(0, 10) : "", disabled: endConditionOf(value) !== "until", onChange: (e) => update({ until: new Date(e.target.value).toISOString() }), "aria-label": "End date" })] })] }), ((value.freq !== "weekly" || (value.byDay ?? []).length > 0)) && (_jsxs("p", { className: "text-xs text-text-muted", children: ["Repeats ", describeRecurrence(value), "."] }))] }))] }));
55
+ }
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { listResourceMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
10
+ /**
11
+ * A searchable dropdown of bookable resource mailboxes (rooms/equipment), for adding one as a `"resource"`
12
+ * attendee in `EventModal` without typing its raw address. Fetches the caller's full visible resource-mailbox
13
+ * list once on open and filters it client-side by name/address as the reader types — the same
14
+ * "fetch-flat-list, filter-client-side" contract every other list endpoint in this codebase already uses
15
+ * (see `calendarApi.ts`'s `listCalendarEvents` doc comment), since there is no dedicated server-side search
16
+ * endpoint for mailboxes and the resource-mailbox count in any one org is expected to be small.
17
+ */
18
+ export default function ResourcePicker({ anchorRef, onClose, onSelect, excludeAddresses }) {
19
+ const [resources, setResources] = useState(null);
20
+ const [error, setError] = useState(null);
21
+ const [query, setQuery] = useState("");
22
+ useEffect(() => {
23
+ listResourceMailboxes({ limit: 100 })
24
+ .then(setResources)
25
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load resources."));
26
+ }, []);
27
+ const excluded = new Set(excludeAddresses);
28
+ const filtered = (resources ?? []).filter((mailbox) => {
29
+ if (excluded.has(mailbox.primarySmtpAddress.toLowerCase())) {
30
+ return false;
31
+ }
32
+ const haystack = `${mailbox.displayName} ${mailbox.primarySmtpAddress}`.toLowerCase();
33
+ return haystack.includes(query.trim().toLowerCase());
34
+ });
35
+ return (_jsxs(PopoverPortal, { anchorRef: anchorRef, onClose: onClose, width: 280, height: 320, "aria-label": "Add a room or equipment resource", children: [_jsx("input", { type: "text", autoFocus: true, placeholder: "Search rooms & equipment\u2026", className: "text-sm py-2 px-3 border-b border-border bg-surface text-text focus:outline-none", value: query, onChange: (e) => setQuery(e.target.value) }), _jsxs("div", { className: "flex-1 overflow-y-auto", children: [error && _jsx("p", { className: "text-xs text-danger p-3", children: error }), !error && resources === null && _jsx("p", { className: "text-xs text-text-muted p-3", children: "Loading\u2026" }), !error && resources !== null && filtered.length === 0 && (_jsx("p", { className: "text-xs text-text-muted p-3", children: "No matching resources." })), filtered.map((mailbox) => (_jsxs("button", { type: "button", onClick: () => onSelect(mailbox), className: "w-full text-left px-3 py-2 text-sm hover:bg-surface-alt flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate", children: mailbox.displayName }), _jsxs("span", { className: "text-xs text-text-muted shrink-0 capitalize", children: [mailbox.resourceType ?? "room", mailbox.resourceCapacity ? ` · ${mailbox.resourceCapacity}` : ""] })] }, mailbox.uid)))] })] }));
36
+ }