@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.
- package/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- package/package.json +87 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { RecurrenceFrequency, RecurrenceRule, WeekdayCode } from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
7
|
+
import { WEEKDAY_CODES, WEEKDAY_LABELS, describeRecurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
8
|
+
|
|
9
|
+
const SELECT_CLASS =
|
|
10
|
+
"text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
11
|
+
const INPUT_CLASS =
|
|
12
|
+
"text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
13
|
+
|
|
14
|
+
const FREQ_LABEL: Record<RecurrenceFrequency, { unit: string; unitPlural: string }> = {
|
|
15
|
+
daily: { unit: "day", unitPlural: "days" },
|
|
16
|
+
weekly: { unit: "week", unitPlural: "weeks" },
|
|
17
|
+
monthly: { unit: "month", unitPlural: "months" },
|
|
18
|
+
yearly: { unit: "year", unitPlural: "years" },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type EndCondition = "never" | "count" | "until";
|
|
22
|
+
|
|
23
|
+
function endConditionOf(rule: RecurrenceRule): EndCondition {
|
|
24
|
+
if (rule.count) return "count";
|
|
25
|
+
if (rule.until) return "until";
|
|
26
|
+
return "never";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RecurrenceEditorProps {
|
|
30
|
+
/** `null` means "does not repeat". */
|
|
31
|
+
value: RecurrenceRule | null;
|
|
32
|
+
onChange: (value: RecurrenceRule | null) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A recurring-event editor: frequency + interval, a weekday picker (weekly only), and an end
|
|
37
|
+
* condition (never / after N occurrences / on a date) — everything `RecurrenceRule` can express. The
|
|
38
|
+
* live "every ... until/for ..." summary comes from `describeRecurrence()` (built on `rrule`'s own
|
|
39
|
+
* `.toText()`), so it never drifts out of sync with what will actually be submitted.
|
|
40
|
+
*/
|
|
41
|
+
export default function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
|
42
|
+
function handleEnable(enabled: boolean) {
|
|
43
|
+
onChange(enabled ? { freq: "weekly", interval: 1, byDay: ["MO"], exceptions: [] } : null);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `update`/`toggleDay` are only ever invoked from handlers rendered inside the `{value && (...)}`
|
|
47
|
+
// block below, so `value` is always non-null here — no defensive null check needed.
|
|
48
|
+
function update(patch: Partial<RecurrenceRule>) {
|
|
49
|
+
onChange({ ...(value as RecurrenceRule), ...patch });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toggleDay(day: WeekdayCode) {
|
|
53
|
+
const current = (value as RecurrenceRule).byDay ?? [];
|
|
54
|
+
const next = current.includes(day) ? current.filter((d) => d !== day) : [...current, day];
|
|
55
|
+
update({ byDay: next });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function handleEndCondition(condition: EndCondition) {
|
|
59
|
+
if (condition === "never") {
|
|
60
|
+
update({ count: undefined, until: undefined });
|
|
61
|
+
} else if (condition === "count") {
|
|
62
|
+
update({ count: 10, until: undefined });
|
|
63
|
+
} else {
|
|
64
|
+
update({ until: new Date().toISOString(), count: undefined });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex flex-col gap-3">
|
|
70
|
+
<label className="flex items-center gap-2 text-sm font-medium">
|
|
71
|
+
<input type="checkbox" checked={value !== null} onChange={(e) => handleEnable(e.target.checked)} />
|
|
72
|
+
Repeats
|
|
73
|
+
</label>
|
|
74
|
+
|
|
75
|
+
{value && (
|
|
76
|
+
<div className="flex flex-col gap-3 pl-6 border-l-2 border-border">
|
|
77
|
+
<div className="flex items-center gap-2 text-sm">
|
|
78
|
+
<span>Every</span>
|
|
79
|
+
<input
|
|
80
|
+
type="number"
|
|
81
|
+
min={1}
|
|
82
|
+
className={`${INPUT_CLASS} w-16`}
|
|
83
|
+
value={value.interval}
|
|
84
|
+
onChange={(e) => update({ interval: Number(e.target.value) })}
|
|
85
|
+
aria-label="Recurrence interval"
|
|
86
|
+
/>
|
|
87
|
+
<select
|
|
88
|
+
className={SELECT_CLASS}
|
|
89
|
+
value={value.freq}
|
|
90
|
+
onChange={(e) => update({ freq: e.target.value as RecurrenceFrequency })}
|
|
91
|
+
aria-label="Recurrence frequency"
|
|
92
|
+
>
|
|
93
|
+
<option value="daily">{value.interval === 1 ? FREQ_LABEL.daily.unit : FREQ_LABEL.daily.unitPlural}</option>
|
|
94
|
+
<option value="weekly">{value.interval === 1 ? FREQ_LABEL.weekly.unit : FREQ_LABEL.weekly.unitPlural}</option>
|
|
95
|
+
<option value="monthly">{value.interval === 1 ? FREQ_LABEL.monthly.unit : FREQ_LABEL.monthly.unitPlural}</option>
|
|
96
|
+
<option value="yearly">{value.interval === 1 ? FREQ_LABEL.yearly.unit : FREQ_LABEL.yearly.unitPlural}</option>
|
|
97
|
+
</select>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{value.freq === "weekly" && (
|
|
101
|
+
<div className="flex gap-1">
|
|
102
|
+
{WEEKDAY_CODES.map((day) => (
|
|
103
|
+
<button
|
|
104
|
+
key={day}
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => toggleDay(day)}
|
|
107
|
+
aria-pressed={(value.byDay ?? []).includes(day)}
|
|
108
|
+
aria-label={WEEKDAY_LABELS[day]}
|
|
109
|
+
className={[
|
|
110
|
+
"w-9 h-9 rounded-full text-xs font-semibold border",
|
|
111
|
+
(value.byDay ?? []).includes(day)
|
|
112
|
+
? "bg-primary text-white border-primary"
|
|
113
|
+
: "bg-surface text-text-muted border-border hover:border-primary",
|
|
114
|
+
].join(" ")}
|
|
115
|
+
>
|
|
116
|
+
<span aria-hidden="true">{WEEKDAY_LABELS[day][0]}</span>
|
|
117
|
+
</button>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
<fieldset className="flex flex-col gap-1.5 text-sm">
|
|
123
|
+
<legend className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1">Ends</legend>
|
|
124
|
+
<label className="flex items-center gap-2">
|
|
125
|
+
<input
|
|
126
|
+
type="radio"
|
|
127
|
+
name="recurrence-end"
|
|
128
|
+
checked={endConditionOf(value) === "never"}
|
|
129
|
+
onChange={() => handleEndCondition("never")}
|
|
130
|
+
/>
|
|
131
|
+
Never
|
|
132
|
+
</label>
|
|
133
|
+
<label className="flex items-center gap-2">
|
|
134
|
+
<input
|
|
135
|
+
type="radio"
|
|
136
|
+
name="recurrence-end"
|
|
137
|
+
aria-label="After"
|
|
138
|
+
checked={endConditionOf(value) === "count"}
|
|
139
|
+
onChange={() => handleEndCondition("count")}
|
|
140
|
+
/>
|
|
141
|
+
After
|
|
142
|
+
<input
|
|
143
|
+
type="number"
|
|
144
|
+
min={1}
|
|
145
|
+
className={`${INPUT_CLASS} w-16`}
|
|
146
|
+
value={value.count ?? 10}
|
|
147
|
+
disabled={endConditionOf(value) !== "count"}
|
|
148
|
+
onChange={(e) => update({ count: Number(e.target.value) })}
|
|
149
|
+
aria-label="Number of occurrences"
|
|
150
|
+
/>
|
|
151
|
+
occurrences
|
|
152
|
+
</label>
|
|
153
|
+
<label className="flex items-center gap-2">
|
|
154
|
+
<input
|
|
155
|
+
type="radio"
|
|
156
|
+
name="recurrence-end"
|
|
157
|
+
aria-label="On"
|
|
158
|
+
checked={endConditionOf(value) === "until"}
|
|
159
|
+
onChange={() => handleEndCondition("until")}
|
|
160
|
+
/>
|
|
161
|
+
On
|
|
162
|
+
<input
|
|
163
|
+
type="date"
|
|
164
|
+
className={INPUT_CLASS}
|
|
165
|
+
value={value.until ? value.until.slice(0, 10) : ""}
|
|
166
|
+
disabled={endConditionOf(value) !== "until"}
|
|
167
|
+
onChange={(e) => update({ until: new Date(e.target.value).toISOString() })}
|
|
168
|
+
aria-label="End date"
|
|
169
|
+
/>
|
|
170
|
+
</label>
|
|
171
|
+
</fieldset>
|
|
172
|
+
|
|
173
|
+
{((value.freq !== "weekly" || (value.byDay ?? []).length > 0)) && (
|
|
174
|
+
<p className="text-xs text-text-muted">Repeats {describeRecurrence(value)}.</p>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { RefObject, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { Mailbox, listResourceMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
|
|
9
|
+
|
|
10
|
+
export interface ResourcePickerProps {
|
|
11
|
+
anchorRef: RefObject<HTMLElement | null>;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
onSelect: (mailbox: Mailbox) => void;
|
|
14
|
+
/** Addresses already added as attendees (lowercased by the caller) — excluded from the list so the
|
|
15
|
+
* same room/equipment mailbox can't be picked twice. */
|
|
16
|
+
excludeAddresses: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A searchable dropdown of bookable resource mailboxes (rooms/equipment), for adding one as a `"resource"`
|
|
21
|
+
* attendee in `EventModal` without typing its raw address. Fetches the caller's full visible resource-mailbox
|
|
22
|
+
* list once on open and filters it client-side by name/address as the reader types — the same
|
|
23
|
+
* "fetch-flat-list, filter-client-side" contract every other list endpoint in this codebase already uses
|
|
24
|
+
* (see `calendarApi.ts`'s `listCalendarEvents` doc comment), since there is no dedicated server-side search
|
|
25
|
+
* endpoint for mailboxes and the resource-mailbox count in any one org is expected to be small.
|
|
26
|
+
*/
|
|
27
|
+
export default function ResourcePicker({ anchorRef, onClose, onSelect, excludeAddresses }: ResourcePickerProps) {
|
|
28
|
+
const [resources, setResources] = useState<Mailbox[] | null>(null);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
const [query, setQuery] = useState("");
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
listResourceMailboxes({ limit: 100 })
|
|
34
|
+
.then(setResources)
|
|
35
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load resources."));
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const excluded = new Set(excludeAddresses);
|
|
39
|
+
const filtered = (resources ?? []).filter((mailbox) => {
|
|
40
|
+
if (excluded.has(mailbox.primarySmtpAddress.toLowerCase())) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const haystack = `${mailbox.displayName} ${mailbox.primarySmtpAddress}`.toLowerCase();
|
|
44
|
+
return haystack.includes(query.trim().toLowerCase());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<PopoverPortal anchorRef={anchorRef} onClose={onClose} width={280} height={320} aria-label="Add a room or equipment resource">
|
|
49
|
+
<input
|
|
50
|
+
type="text"
|
|
51
|
+
autoFocus
|
|
52
|
+
placeholder="Search rooms & equipment…"
|
|
53
|
+
className="text-sm py-2 px-3 border-b border-border bg-surface text-text focus:outline-none"
|
|
54
|
+
value={query}
|
|
55
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
56
|
+
/>
|
|
57
|
+
<div className="flex-1 overflow-y-auto">
|
|
58
|
+
{error && <p className="text-xs text-danger p-3">{error}</p>}
|
|
59
|
+
{!error && resources === null && <p className="text-xs text-text-muted p-3">Loading…</p>}
|
|
60
|
+
{!error && resources !== null && filtered.length === 0 && (
|
|
61
|
+
<p className="text-xs text-text-muted p-3">No matching resources.</p>
|
|
62
|
+
)}
|
|
63
|
+
{filtered.map((mailbox) => (
|
|
64
|
+
<button
|
|
65
|
+
key={mailbox.uid}
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={() => onSelect(mailbox)}
|
|
68
|
+
className="w-full text-left px-3 py-2 text-sm hover:bg-surface-alt flex items-center justify-between gap-2"
|
|
69
|
+
>
|
|
70
|
+
<span className="truncate">{mailbox.displayName}</span>
|
|
71
|
+
<span className="text-xs text-text-muted shrink-0 capitalize">
|
|
72
|
+
{mailbox.resourceType ?? "room"}
|
|
73
|
+
{mailbox.resourceCapacity ? ` · ${mailbox.resourceCapacity}` : ""}
|
|
74
|
+
</span>
|
|
75
|
+
</button>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
</PopoverPortal>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { addMinutes, format, isSameDay, startOfDay } from "date-fns";
|
|
7
|
+
import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
8
|
+
|
|
9
|
+
const HOUR_HEIGHT_PX = 48;
|
|
10
|
+
const SLOT_MINUTES = 30;
|
|
11
|
+
const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
|
|
12
|
+
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
|
13
|
+
|
|
14
|
+
export interface SplitDayColumn {
|
|
15
|
+
folderUid: string;
|
|
16
|
+
name: string;
|
|
17
|
+
color: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SplitDayViewProps {
|
|
21
|
+
day: Date;
|
|
22
|
+
columns: SplitDayColumn[];
|
|
23
|
+
/** Already expanded for `day` — see `recurrence.ts`'s `expandAllOccurrences`. Not pre-filtered by
|
|
24
|
+
* calendar; this component splits them into columns itself by `occurrence.folderUid`. */
|
|
25
|
+
occurrences: CalendarOccurrence[];
|
|
26
|
+
onSelectEvent: (occurrence: CalendarOccurrence) => void;
|
|
27
|
+
/** Click on an empty slot in one calendar's column — the calendar it belongs to is `folderUid`. */
|
|
28
|
+
onSelectSlot: (start: Date, end: Date, folderUid: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Outlook's "Split" view: one column per checked calendar, all showing the same single day, so
|
|
33
|
+
* events across calendars can be compared side by side. A deliberately separate component from
|
|
34
|
+
* `TimeGridView` rather than a generalization of it — it shares that component's visual language
|
|
35
|
+
* (same hour rail, same slot height) but is genuinely simpler in one respect: no drag-to-move/resize.
|
|
36
|
+
* Dragging an event between calendar columns would mean reassigning its `folderUid`, a real feature
|
|
37
|
+
* `moveOccurrence`/`resolveDragAction` don't support today; rather than bolt that on, this view is
|
|
38
|
+
* click-only (select an event, or click a slot to create one already targeted at that column's
|
|
39
|
+
* calendar) — a practical scope cut, not an oversight.
|
|
40
|
+
*/
|
|
41
|
+
export default function SplitDayView({ day, columns, occurrences, onSelectEvent, onSelectSlot }: SplitDayViewProps) {
|
|
42
|
+
const dayOccurrences = occurrences.filter((occ) => isSameDay(new Date(occ.startDate), day));
|
|
43
|
+
const dayStart = startOfDay(day);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div role="region" aria-label="Split view" className="flex-1 flex flex-col min-h-0 overflow-y-auto">
|
|
47
|
+
<div className="flex border-b border-border shrink-0">
|
|
48
|
+
<div className="w-14 shrink-0" />
|
|
49
|
+
{columns.map((col) => (
|
|
50
|
+
<div key={col.folderUid} className="flex-1 min-w-0 py-1 flex items-center justify-center gap-1.5 border-l border-border">
|
|
51
|
+
<span aria-hidden="true" className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: col.color }} />
|
|
52
|
+
<span className="text-xs font-semibold truncate">{col.name}</span>
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
<div className="flex flex-1">
|
|
57
|
+
<div className="w-14 shrink-0">
|
|
58
|
+
{HOURS.map((hour) => (
|
|
59
|
+
<div key={hour} style={{ height: HOUR_HEIGHT_PX }} className="text-[10px] text-text-muted text-right pr-1.5 -mt-1.5">
|
|
60
|
+
{hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha")}
|
|
61
|
+
</div>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
{columns.map((col) => (
|
|
65
|
+
<SplitColumn
|
|
66
|
+
key={col.folderUid}
|
|
67
|
+
column={col}
|
|
68
|
+
dayStart={dayStart}
|
|
69
|
+
occurrences={dayOccurrences.filter((occ) => occ.folderUid === col.folderUid)}
|
|
70
|
+
onSelectEvent={onSelectEvent}
|
|
71
|
+
onSelectSlot={onSelectSlot}
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function SplitColumn({
|
|
80
|
+
column,
|
|
81
|
+
dayStart,
|
|
82
|
+
occurrences,
|
|
83
|
+
onSelectEvent,
|
|
84
|
+
onSelectSlot,
|
|
85
|
+
}: {
|
|
86
|
+
column: SplitDayColumn;
|
|
87
|
+
dayStart: Date;
|
|
88
|
+
occurrences: CalendarOccurrence[];
|
|
89
|
+
onSelectEvent: (occurrence: CalendarOccurrence) => void;
|
|
90
|
+
onSelectSlot: (start: Date, end: Date, folderUid: string) => void;
|
|
91
|
+
}) {
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex-1 min-w-0 relative border-l border-border">
|
|
94
|
+
{Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
|
|
95
|
+
const slotStart = addMinutes(dayStart, i * SLOT_MINUTES);
|
|
96
|
+
return (
|
|
97
|
+
<button
|
|
98
|
+
key={i}
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => onSelectSlot(slotStart, addMinutes(slotStart, SLOT_MINUTES), column.folderUid)}
|
|
101
|
+
style={{ height: HOUR_HEIGHT_PX / 2 }}
|
|
102
|
+
className="block w-full border-b border-border/50 text-left"
|
|
103
|
+
aria-label={`New event at ${format(slotStart, "h:mm a")} in ${column.name}`}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
})}
|
|
107
|
+
{occurrences.map((occurrence) => {
|
|
108
|
+
const start = new Date(occurrence.startDate);
|
|
109
|
+
const end = new Date(occurrence.endDate);
|
|
110
|
+
const top = ((start.getTime() - dayStart.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX;
|
|
111
|
+
const height = Math.max(((end.getTime() - start.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX, 16);
|
|
112
|
+
const isFree = occurrence.busyStatus === "free";
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
key={occurrence.occurrenceKey}
|
|
116
|
+
onClick={() => onSelectEvent(occurrence)}
|
|
117
|
+
style={{
|
|
118
|
+
position: "absolute",
|
|
119
|
+
top,
|
|
120
|
+
height,
|
|
121
|
+
left: 2,
|
|
122
|
+
right: 2,
|
|
123
|
+
...(isFree ? undefined : { backgroundColor: column.color, color: "#fff" }),
|
|
124
|
+
}}
|
|
125
|
+
className={[
|
|
126
|
+
"rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
|
|
127
|
+
isFree ? "bg-surface-alt text-text-muted" : "",
|
|
128
|
+
].join(" ")}
|
|
129
|
+
>
|
|
130
|
+
<div className="font-medium truncate">{occurrence.title}</div>
|
|
131
|
+
<div className="truncate">{format(start, "h:mma")}</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
})}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
|
7
|
+
import { addMinutes, format, isSameDay, isToday, startOfDay } from "date-fns";
|
|
8
|
+
import { dayDropId, eventDragId, resizeDragId, slotDropId } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
|
|
9
|
+
import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
10
|
+
|
|
11
|
+
const HOUR_HEIGHT_PX = 48;
|
|
12
|
+
const SLOT_MINUTES = 30;
|
|
13
|
+
const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
|
|
14
|
+
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
|
15
|
+
|
|
16
|
+
export interface TimeGridViewProps {
|
|
17
|
+
/** One day for the Day view, seven for the Week view, five for the Work Week view. */
|
|
18
|
+
days: Date[];
|
|
19
|
+
/** Already expanded for the visible range — see `recurrence.ts`'s `expandAllOccurrences`. */
|
|
20
|
+
occurrences: CalendarOccurrence[];
|
|
21
|
+
/** Each occurrence's own calendar color, keyed by `folderUid` — see `calendarColors.ts`. */
|
|
22
|
+
folderColors: Record<string, string>;
|
|
23
|
+
onSelectEvent: (occurrence: CalendarOccurrence) => void;
|
|
24
|
+
/** Click on an empty slot — start/end default to a 30-minute block there, for the "New event" flow. */
|
|
25
|
+
onSelectSlot: (start: Date, end: Date) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An hourly time grid (Week, Work Week, or Day view — they share all their rendering/drag logic and
|
|
29
|
+
* differ only in how many day columns are shown), with a day-header row and all-day events in a
|
|
30
|
+
* header strip above it. */
|
|
31
|
+
export default function TimeGridView({ days, occurrences, folderColors, onSelectEvent, onSelectSlot }: TimeGridViewProps) {
|
|
32
|
+
const allDayEvents = occurrences.filter((occ) => occ.allDay);
|
|
33
|
+
const timedEvents = occurrences.filter((occ) => !occ.allDay);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
|
|
37
|
+
<div className="flex border-b border-border shrink-0">
|
|
38
|
+
<div className="w-14 shrink-0" />
|
|
39
|
+
{days.map((day) => (
|
|
40
|
+
<div
|
|
41
|
+
key={day.toISOString()}
|
|
42
|
+
className={["flex-1 min-w-0 py-1 text-center text-xs font-semibold border-l border-border", isToday(day) ? "text-primary-dark" : ""].join(" ")}
|
|
43
|
+
>
|
|
44
|
+
{format(day, "EEE d")}
|
|
45
|
+
</div>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
{allDayEvents.length > 0 && (
|
|
49
|
+
<div className="flex border-b border-border shrink-0">
|
|
50
|
+
<div className="w-14 shrink-0" />
|
|
51
|
+
{days.map((day) => (
|
|
52
|
+
<div key={day.toISOString()} className="flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border">
|
|
53
|
+
{allDayEvents
|
|
54
|
+
.filter((occ) => isSameDay(new Date(occ.startDate), day))
|
|
55
|
+
.map((occ) => (
|
|
56
|
+
<button
|
|
57
|
+
key={occ.occurrenceKey}
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={() => onSelectEvent(occ)}
|
|
60
|
+
style={{ backgroundColor: folderColors[occ.folderUid], color: "#fff" }}
|
|
61
|
+
className="text-xs text-left truncate rounded-sm px-1.5 py-0.5"
|
|
62
|
+
>
|
|
63
|
+
{occ.title}
|
|
64
|
+
</button>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
<div className="flex flex-1">
|
|
71
|
+
<div className="w-14 shrink-0">
|
|
72
|
+
{HOURS.map((hour) => (
|
|
73
|
+
<div key={hour} style={{ height: HOUR_HEIGHT_PX }} className="text-[10px] text-text-muted text-right pr-1.5 -mt-1.5">
|
|
74
|
+
{hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha")}
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
{days.map((day) => (
|
|
79
|
+
<DayColumn
|
|
80
|
+
key={day.toISOString()}
|
|
81
|
+
day={day}
|
|
82
|
+
occurrences={timedEvents.filter((occ) => isSameDay(new Date(occ.startDate), day))}
|
|
83
|
+
folderColors={folderColors}
|
|
84
|
+
onSelectEvent={onSelectEvent}
|
|
85
|
+
onSelectSlot={onSelectSlot}
|
|
86
|
+
/>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface DayColumnProps {
|
|
94
|
+
day: Date;
|
|
95
|
+
occurrences: CalendarOccurrence[];
|
|
96
|
+
folderColors: Record<string, string>;
|
|
97
|
+
onSelectEvent: (occurrence: CalendarOccurrence) => void;
|
|
98
|
+
onSelectSlot: (start: Date, end: Date) => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function DayColumn({ day, occurrences, folderColors, onSelectEvent, onSelectSlot }: DayColumnProps) {
|
|
102
|
+
const dayStart = startOfDay(day);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className={["flex-1 min-w-0 relative border-l border-border", isToday(day) ? "bg-primary/5" : ""].join(" ")}>
|
|
106
|
+
{Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
|
|
107
|
+
const slotStart = addMinutes(dayStart, i * SLOT_MINUTES);
|
|
108
|
+
return <TimeSlot key={i} start={slotStart} onSelectSlot={onSelectSlot} />;
|
|
109
|
+
})}
|
|
110
|
+
{occurrences.map((occurrence) => (
|
|
111
|
+
<EventBlock
|
|
112
|
+
key={occurrence.occurrenceKey}
|
|
113
|
+
occurrence={occurrence}
|
|
114
|
+
color={folderColors[occurrence.folderUid]}
|
|
115
|
+
dayStart={dayStart}
|
|
116
|
+
onSelect={onSelectEvent}
|
|
117
|
+
/>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function TimeSlot({ start, onSelectSlot }: { start: Date; onSelectSlot: (start: Date, end: Date) => void }) {
|
|
124
|
+
const { setNodeRef, isOver } = useDroppable({ id: slotDropId(start) });
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
ref={setNodeRef}
|
|
128
|
+
type="button"
|
|
129
|
+
onClick={() => onSelectSlot(start, addMinutes(start, SLOT_MINUTES))}
|
|
130
|
+
style={{ height: HOUR_HEIGHT_PX / 2 }}
|
|
131
|
+
className={["block w-full border-b border-border/50 text-left", isOver ? "bg-primary/10" : ""].join(" ")}
|
|
132
|
+
aria-label={`New event at ${format(start, "h:mm a, MMM d")}`}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function EventBlock({
|
|
138
|
+
occurrence,
|
|
139
|
+
color,
|
|
140
|
+
dayStart,
|
|
141
|
+
onSelect,
|
|
142
|
+
}: {
|
|
143
|
+
occurrence: CalendarOccurrence;
|
|
144
|
+
/** This occurrence's calendar color — see `calendarColors.ts`. Only applied for a "busy" block; a
|
|
145
|
+
* "free" one keeps Outlook's own muted/outline treatment regardless of which calendar it's on. */
|
|
146
|
+
color: string;
|
|
147
|
+
dayStart: Date;
|
|
148
|
+
onSelect: (occurrence: CalendarOccurrence) => void;
|
|
149
|
+
}) {
|
|
150
|
+
const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
|
|
151
|
+
const { setNodeRef: setResizeRef, listeners: resizeListeners, attributes: resizeAttributes } = useDraggable({
|
|
152
|
+
id: resizeDragId(occurrence),
|
|
153
|
+
});
|
|
154
|
+
const isFree = occurrence.busyStatus === "free";
|
|
155
|
+
|
|
156
|
+
const start = new Date(occurrence.startDate);
|
|
157
|
+
const end = new Date(occurrence.endDate);
|
|
158
|
+
const top = ((start.getTime() - dayStart.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX;
|
|
159
|
+
// The resize handle's own drag position has no live pixel preview (it only applies its ns-resize
|
|
160
|
+
// affordance) — the actual new end time is computed from whichever slot it's dropped onto (see
|
|
161
|
+
// `resolveDragAction`'s "resize" case), snapped to the grid rather than following the pointer
|
|
162
|
+
// continuously. Simpler, and avoids a visual preview that could disagree with the snapped result.
|
|
163
|
+
const height = Math.max(((end.getTime() - start.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX, 16);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div
|
|
167
|
+
ref={setNodeRef}
|
|
168
|
+
onClick={() => onSelect(occurrence)}
|
|
169
|
+
style={{
|
|
170
|
+
position: "absolute",
|
|
171
|
+
top,
|
|
172
|
+
height,
|
|
173
|
+
left: 2,
|
|
174
|
+
right: 2,
|
|
175
|
+
transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined,
|
|
176
|
+
zIndex: isDragging ? 10 : undefined,
|
|
177
|
+
...(isFree ? undefined : { backgroundColor: color, color: "#fff" }),
|
|
178
|
+
}}
|
|
179
|
+
className={[
|
|
180
|
+
"rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
|
|
181
|
+
isFree ? "bg-surface-alt text-text-muted" : "",
|
|
182
|
+
isDragging ? "opacity-50" : "",
|
|
183
|
+
].join(" ")}
|
|
184
|
+
{...listeners}
|
|
185
|
+
{...attributes}
|
|
186
|
+
>
|
|
187
|
+
<div className="font-medium truncate">{occurrence.title}</div>
|
|
188
|
+
<div className="truncate">{format(start, "h:mma")}</div>
|
|
189
|
+
<div
|
|
190
|
+
ref={setResizeRef}
|
|
191
|
+
className="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize"
|
|
192
|
+
aria-label={`Resize "${occurrence.title}"`}
|
|
193
|
+
{...resizeListeners}
|
|
194
|
+
{...resizeAttributes}
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|