@wealthx/shadcn 1.5.22 → 1.5.24
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/.turbo/turbo-build.log +58 -58
- package/CHANGELOG.md +12 -0
- package/dist/{chunk-NEMWMXGL.mjs → chunk-3HFOSFOM.mjs} +76 -48
- package/dist/{chunk-F3MIRXRF.mjs → chunk-ONYADWSO.mjs} +7 -1
- package/dist/{chunk-K4GJTP6N.mjs → chunk-RYGZRDP6.mjs} +24 -1
- package/dist/{chunk-SET6GFGL.mjs → chunk-ZSMQZ3VN.mjs} +96 -73
- package/dist/components/ui/ai-conversations.js +24 -1
- package/dist/components/ui/ai-conversations.mjs +1 -1
- package/dist/components/ui/appointment-action-dialogs.js +96 -73
- package/dist/components/ui/appointment-action-dialogs.mjs +1 -1
- package/dist/components/ui/appointment-book-dialog.js +76 -48
- package/dist/components/ui/appointment-book-dialog.mjs +1 -1
- package/dist/components/ui/appointment-detail-sheet.js +102 -73
- package/dist/components/ui/appointment-detail-sheet.mjs +2 -2
- package/dist/index.js +204 -124
- package/dist/index.mjs +4 -4
- package/package.json +1 -1
- package/src/components/ui/ai-conversations.tsx +58 -1
- package/src/components/ui/appointment-action-dialogs.tsx +67 -21
- package/src/components/ui/appointment-book-dialog.tsx +150 -53
- package/src/components/ui/appointment-detail-sheet.tsx +18 -0
|
@@ -684,6 +684,15 @@ export interface ChatComposerProps {
|
|
|
684
684
|
/** Active reply channel. Defaults to "chat". */
|
|
685
685
|
channel?: AiConvChannel;
|
|
686
686
|
onChannelChange?: (channel: AiConvChannel) => void;
|
|
687
|
+
/**
|
|
688
|
+
* The channel this conversation belongs to.
|
|
689
|
+
*
|
|
690
|
+
* - `"chat"` (default) — chat composer shown normally.
|
|
691
|
+
* - `"email"` + `isEmailIntegrated=true` — email tab is active by default.
|
|
692
|
+
* - `"email"` + `isEmailIntegrated=false` — composer is replaced with a
|
|
693
|
+
* prompt to integrate email.
|
|
694
|
+
*/
|
|
695
|
+
channelType?: AiConvChannel;
|
|
687
696
|
/**
|
|
688
697
|
* When true, the Email tab is shown in the composer. Defaults to false —
|
|
689
698
|
* consumers must opt in once their tenant's email integration is wired up.
|
|
@@ -807,6 +816,7 @@ export function ChatComposer({
|
|
|
807
816
|
mode,
|
|
808
817
|
channel: channelProp = "chat",
|
|
809
818
|
onChannelChange,
|
|
819
|
+
channelType = "chat",
|
|
810
820
|
isEmailIntegrated = false,
|
|
811
821
|
contactEmail = "",
|
|
812
822
|
inputValue = "",
|
|
@@ -817,10 +827,16 @@ export function ChatComposer({
|
|
|
817
827
|
onLetAiHandle,
|
|
818
828
|
className,
|
|
819
829
|
}: ChatComposerProps) {
|
|
830
|
+
const showIntegrateEmailPrompt =
|
|
831
|
+
channelType === "email" && !isEmailIntegrated;
|
|
832
|
+
|
|
820
833
|
// Semi-controlled: owns channel state for uncontrolled usage, notifies parent on change.
|
|
834
|
+
// When channelType is email and integrated, default to email tab.
|
|
835
|
+
const initialChannel =
|
|
836
|
+
channelType === "email" && isEmailIntegrated ? "email" : channelProp;
|
|
821
837
|
// Force chat when email isn't integrated so the panel never lands on a hidden tab.
|
|
822
838
|
const [channel, setChannel] = React.useState<AiConvChannel>(
|
|
823
|
-
isEmailIntegrated ?
|
|
839
|
+
isEmailIntegrated ? initialChannel : "chat",
|
|
824
840
|
);
|
|
825
841
|
const [emailTo, setEmailTo] = React.useState(contactEmail);
|
|
826
842
|
const [emailCc, setEmailCc] = React.useState("");
|
|
@@ -850,6 +866,25 @@ export function ChatComposer({
|
|
|
850
866
|
onChannelChange?.(c);
|
|
851
867
|
};
|
|
852
868
|
|
|
869
|
+
if (showIntegrateEmailPrompt) {
|
|
870
|
+
return (
|
|
871
|
+
<div
|
|
872
|
+
className={cn(
|
|
873
|
+
"flex flex-col items-center justify-center gap-2 border-t border-border bg-muted/30 px-6 py-8 text-center",
|
|
874
|
+
className,
|
|
875
|
+
)}
|
|
876
|
+
>
|
|
877
|
+
<Mail className="h-8 w-8 text-muted-foreground" />
|
|
878
|
+
<p className="text-sm font-medium text-foreground">
|
|
879
|
+
Email integration required
|
|
880
|
+
</p>
|
|
881
|
+
<p className="text-xs text-muted-foreground">
|
|
882
|
+
Please integrate your email to reply to this conversation.
|
|
883
|
+
</p>
|
|
884
|
+
</div>
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
853
888
|
return (
|
|
854
889
|
<div
|
|
855
890
|
className={cn(
|
|
@@ -1030,6 +1065,15 @@ export interface ChatThreadProps {
|
|
|
1030
1065
|
/** Active reply channel — "chat" (default) or "email". */
|
|
1031
1066
|
channel?: AiConvChannel;
|
|
1032
1067
|
onChannelChange?: (channel: AiConvChannel) => void;
|
|
1068
|
+
/**
|
|
1069
|
+
* The channel this conversation belongs to.
|
|
1070
|
+
*
|
|
1071
|
+
* - `"chat"` (default) — chat composer shown normally.
|
|
1072
|
+
* - `"email"` + `isEmailIntegrated=true` — email tab is active by default.
|
|
1073
|
+
* - `"email"` + `isEmailIntegrated=false` — composer is replaced with a
|
|
1074
|
+
* prompt to integrate email.
|
|
1075
|
+
*/
|
|
1076
|
+
channelType?: AiConvChannel;
|
|
1033
1077
|
/**
|
|
1034
1078
|
* When true, the Email tab is shown in the composer. Defaults to false.
|
|
1035
1079
|
*/
|
|
@@ -1068,6 +1112,7 @@ export function ChatThread({
|
|
|
1068
1112
|
isAiTyping = false,
|
|
1069
1113
|
channel,
|
|
1070
1114
|
onChannelChange,
|
|
1115
|
+
channelType,
|
|
1071
1116
|
isEmailIntegrated,
|
|
1072
1117
|
inputValue,
|
|
1073
1118
|
onInputChange,
|
|
@@ -1310,6 +1355,7 @@ export function ChatThread({
|
|
|
1310
1355
|
mode={mode}
|
|
1311
1356
|
channel={channel}
|
|
1312
1357
|
onChannelChange={onChannelChange}
|
|
1358
|
+
channelType={channelType}
|
|
1313
1359
|
isEmailIntegrated={isEmailIntegrated}
|
|
1314
1360
|
contactEmail={contact.email}
|
|
1315
1361
|
inputValue={inputValue}
|
|
@@ -1818,6 +1864,15 @@ export interface ConversationsPageProps {
|
|
|
1818
1864
|
/** Active reply channel — "chat" (default) or "email". */
|
|
1819
1865
|
channel?: AiConvChannel;
|
|
1820
1866
|
onChannelChange?: (channel: AiConvChannel) => void;
|
|
1867
|
+
/**
|
|
1868
|
+
* The channel this conversation belongs to.
|
|
1869
|
+
*
|
|
1870
|
+
* - `"chat"` (default) — chat composer shown normally.
|
|
1871
|
+
* - `"email"` + `isEmailIntegrated=true` — email tab is active by default.
|
|
1872
|
+
* - `"email"` + `isEmailIntegrated=false` — composer is replaced with a
|
|
1873
|
+
* prompt to integrate email.
|
|
1874
|
+
*/
|
|
1875
|
+
channelType?: AiConvChannel;
|
|
1821
1876
|
/**
|
|
1822
1877
|
* When true, the Email tab is shown in the composer. Defaults to false.
|
|
1823
1878
|
*/
|
|
@@ -1884,6 +1939,7 @@ export function ConversationsPage({
|
|
|
1884
1939
|
onChannelFilterChange,
|
|
1885
1940
|
channel,
|
|
1886
1941
|
onChannelChange,
|
|
1942
|
+
channelType,
|
|
1887
1943
|
isEmailIntegrated,
|
|
1888
1944
|
inputValue,
|
|
1889
1945
|
internalNotes,
|
|
@@ -1975,6 +2031,7 @@ export function ConversationsPage({
|
|
|
1975
2031
|
isAiTyping={isAiTyping}
|
|
1976
2032
|
channel={channel}
|
|
1977
2033
|
onChannelChange={onChannelChange}
|
|
2034
|
+
channelType={channelType}
|
|
1978
2035
|
isEmailIntegrated={isEmailIntegrated}
|
|
1979
2036
|
inputValue={inputValue}
|
|
1980
2037
|
onInputChange={onInputChange}
|
|
@@ -138,9 +138,25 @@ export interface AppointmentRescheduleDialogProps {
|
|
|
138
138
|
currentDate?: string;
|
|
139
139
|
currentTimeStart?: string;
|
|
140
140
|
currentTimeEnd?: string;
|
|
141
|
+
/**
|
|
142
|
+
* Fired when the user selects a different date in the calendar.
|
|
143
|
+
* Use this to fetch fresh `amSlots`/`pmSlots` for the new date.
|
|
144
|
+
*/
|
|
145
|
+
onDateChange?: (date: Date) => void;
|
|
146
|
+
/**
|
|
147
|
+
* Advisor's weekly availability schedule. Days with `enabled: false` are
|
|
148
|
+
* disabled in the date picker so the user cannot select them.
|
|
149
|
+
*/
|
|
150
|
+
schedule?: { day: string; enabled: boolean }[];
|
|
151
|
+
/** True while slots are being fetched for the selected date. */
|
|
152
|
+
isLoadingSlots?: boolean;
|
|
141
153
|
onReschedule: (date: Date, slot: AppointmentTimeSlot, note: string) => void;
|
|
142
154
|
}
|
|
143
155
|
|
|
156
|
+
const DAY_MAP: Record<string, number> = {
|
|
157
|
+
Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
|
|
158
|
+
};
|
|
159
|
+
|
|
144
160
|
export function AppointmentRescheduleDialog({
|
|
145
161
|
open,
|
|
146
162
|
onOpenChange,
|
|
@@ -149,12 +165,26 @@ export function AppointmentRescheduleDialog({
|
|
|
149
165
|
currentDate,
|
|
150
166
|
currentTimeStart,
|
|
151
167
|
currentTimeEnd,
|
|
168
|
+
onDateChange,
|
|
169
|
+
schedule,
|
|
170
|
+
isLoadingSlots,
|
|
152
171
|
onReschedule,
|
|
153
172
|
}: AppointmentRescheduleDialogProps) {
|
|
154
173
|
const [date, setDate] = React.useState<Date | undefined>(new Date());
|
|
155
174
|
const [slot, setSlot] = React.useState<AppointmentTimeSlot | undefined>();
|
|
156
175
|
const [note, setNote] = React.useState("");
|
|
157
176
|
|
|
177
|
+
const today = React.useMemo(() => new Date(), []);
|
|
178
|
+
|
|
179
|
+
const disabledDayOfWeek = React.useMemo(
|
|
180
|
+
() =>
|
|
181
|
+
schedule
|
|
182
|
+
?.filter((d) => !d.enabled)
|
|
183
|
+
.map((d) => DAY_MAP[d.day])
|
|
184
|
+
.filter((n): n is number => n !== undefined),
|
|
185
|
+
[schedule],
|
|
186
|
+
);
|
|
187
|
+
|
|
158
188
|
const handleOpenChange = (next: boolean) => {
|
|
159
189
|
if (!next) {
|
|
160
190
|
setDate(new Date());
|
|
@@ -181,6 +211,7 @@ export function AppointmentRescheduleDialog({
|
|
|
181
211
|
|
|
182
212
|
<Separator />
|
|
183
213
|
|
|
214
|
+
<div className="flex flex-col gap-4 overflow-y-auto max-h-[calc(90vh-200px)]">
|
|
184
215
|
{/* Current booking */}
|
|
185
216
|
{(currentDate || currentTimeStart) && (
|
|
186
217
|
<div className="flex flex-col gap-1.5">
|
|
@@ -215,10 +246,15 @@ export function AppointmentRescheduleDialog({
|
|
|
215
246
|
onSelect={(d) => {
|
|
216
247
|
setDate(d);
|
|
217
248
|
setSlot(undefined);
|
|
249
|
+
if (d) onDateChange?.(d);
|
|
218
250
|
}}
|
|
219
251
|
captionLayout="label"
|
|
220
|
-
fromDate={
|
|
221
|
-
disabled={
|
|
252
|
+
fromDate={today}
|
|
253
|
+
disabled={
|
|
254
|
+
disabledDayOfWeek && disabledDayOfWeek.length > 0
|
|
255
|
+
? [{ before: today }, { dayOfWeek: disabledDayOfWeek }]
|
|
256
|
+
: { before: today }
|
|
257
|
+
}
|
|
222
258
|
className="border border-border"
|
|
223
259
|
/>
|
|
224
260
|
</div>
|
|
@@ -226,26 +262,35 @@ export function AppointmentRescheduleDialog({
|
|
|
226
262
|
{/* Time slots */}
|
|
227
263
|
<div className="flex flex-col gap-5">
|
|
228
264
|
{date ? (
|
|
229
|
-
|
|
230
|
-
<div className="flex items-center justify-
|
|
231
|
-
<p className="text-sm font-semibold">
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
</
|
|
265
|
+
isLoadingSlots ? (
|
|
266
|
+
<div className="flex h-full flex-col items-center justify-center gap-2 py-8 text-center">
|
|
267
|
+
<p className="text-sm font-semibold">Loading slots…</p>
|
|
268
|
+
<p className="text-xs text-muted-foreground">
|
|
269
|
+
Fetching available times for the selected date.
|
|
270
|
+
</p>
|
|
235
271
|
</div>
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
272
|
+
) : (
|
|
273
|
+
<>
|
|
274
|
+
<div className="flex items-center justify-between">
|
|
275
|
+
<p className="text-sm font-semibold">Select a time slot</p>
|
|
276
|
+
<span className="text-xs text-muted-foreground">
|
|
277
|
+
{totalAvailable} available
|
|
278
|
+
</span>
|
|
279
|
+
</div>
|
|
280
|
+
<AppointmentSlotSection
|
|
281
|
+
label="Morning"
|
|
282
|
+
slots={amSlots}
|
|
283
|
+
selectedSlotId={slot?.id}
|
|
284
|
+
onSelect={setSlot}
|
|
285
|
+
/>
|
|
286
|
+
<AppointmentSlotSection
|
|
287
|
+
label="Afternoon"
|
|
288
|
+
slots={pmSlots}
|
|
289
|
+
selectedSlotId={slot?.id}
|
|
290
|
+
onSelect={setSlot}
|
|
291
|
+
/>
|
|
292
|
+
</>
|
|
293
|
+
)
|
|
249
294
|
) : (
|
|
250
295
|
<div className="flex h-full flex-col items-center justify-center gap-2 py-8 text-center">
|
|
251
296
|
<p className="text-sm font-semibold">Select a time slot</p>
|
|
@@ -274,6 +319,7 @@ export function AppointmentRescheduleDialog({
|
|
|
274
319
|
rows={2}
|
|
275
320
|
/>
|
|
276
321
|
</div>
|
|
322
|
+
</div>
|
|
277
323
|
|
|
278
324
|
<DialogFooter>
|
|
279
325
|
<DialogClose render={<Button variant="outline" />}>
|
|
@@ -87,6 +87,20 @@ export interface AppointmentBookDialogProps {
|
|
|
87
87
|
* "Book Appointment".
|
|
88
88
|
*/
|
|
89
89
|
clients?: AppointmentClient[];
|
|
90
|
+
/**
|
|
91
|
+
* Fired when the user types in the client search field.
|
|
92
|
+
* Use this to fetch clients from an API based on the query.
|
|
93
|
+
* When omitted, clients are filtered locally by name/email.
|
|
94
|
+
*/
|
|
95
|
+
onSearchClients?: (query: string) => void;
|
|
96
|
+
/** True while a client search request is in-flight. */
|
|
97
|
+
isSearchingClients?: boolean;
|
|
98
|
+
/** True when more client results can be loaded. */
|
|
99
|
+
hasMoreClients?: boolean;
|
|
100
|
+
/** Fired when the user clicks "Load more" in the client dropdown. */
|
|
101
|
+
onLoadMoreClients?: () => void;
|
|
102
|
+
/** True while a load-more request is in-flight. */
|
|
103
|
+
isLoadingMoreClients?: boolean;
|
|
90
104
|
/**
|
|
91
105
|
* Meeting type options. Omit or pass an empty array to hide the meeting
|
|
92
106
|
* type field (useful in client mode where the type is implicit).
|
|
@@ -125,14 +139,25 @@ export interface AppointmentBookDialogProps {
|
|
|
125
139
|
*/
|
|
126
140
|
initialClientId?: string;
|
|
127
141
|
/**
|
|
128
|
-
* Pre-set meeting format
|
|
129
|
-
* In **client mode**, when this is provided the meeting format picker is
|
|
130
|
-
* hidden — the client books using this format only.
|
|
131
|
-
* Has no effect in advisor mode (advisors always see the format picker).
|
|
142
|
+
* Pre-set meeting format when the dialog opens.
|
|
132
143
|
*
|
|
133
144
|
* @remarks Mount-time initialiser only.
|
|
134
145
|
*/
|
|
135
146
|
defaultMeetingFormat?: AppointmentMeetingFormat;
|
|
147
|
+
/**
|
|
148
|
+
* Which online meeting platform the advisor has integrated.
|
|
149
|
+
*
|
|
150
|
+
* - `"google-meet"` — show only Google Meet
|
|
151
|
+
* - `"microsoft-teams"` — show only MS Teams
|
|
152
|
+
* - `"any"` — show both Google Meet and MS Teams
|
|
153
|
+
*
|
|
154
|
+
* In **client mode**, this replaces the generic "Online Meeting" option
|
|
155
|
+
* with the specific platform(s). In **advisor mode** this has no effect
|
|
156
|
+
* (advisors always see all four formats).
|
|
157
|
+
*
|
|
158
|
+
* When omitted in client mode, the generic "Online Meeting" option is shown.
|
|
159
|
+
*/
|
|
160
|
+
onlinePlatform?: "google-meet" | "microsoft-teams" | "any";
|
|
136
161
|
/**
|
|
137
162
|
* Guest identity for **client / public booking mode** only.
|
|
138
163
|
* When all fields are provided the guest form is hidden; when any field is
|
|
@@ -173,58 +198,94 @@ function ClientSearch({
|
|
|
173
198
|
clients,
|
|
174
199
|
value,
|
|
175
200
|
onValueChange,
|
|
201
|
+
onSearch,
|
|
202
|
+
isSearching,
|
|
203
|
+
hasMore,
|
|
204
|
+
onLoadMore,
|
|
205
|
+
isLoadingMore,
|
|
176
206
|
}: {
|
|
177
207
|
clients: AppointmentClient[];
|
|
178
208
|
value: string | undefined;
|
|
179
209
|
onValueChange: (id: string | undefined) => void;
|
|
210
|
+
onSearch?: (query: string) => void;
|
|
211
|
+
isSearching?: boolean;
|
|
212
|
+
hasMore?: boolean;
|
|
213
|
+
onLoadMore?: () => void;
|
|
214
|
+
isLoadingMore?: boolean;
|
|
180
215
|
}) {
|
|
181
216
|
const [query, setQuery] = React.useState("");
|
|
182
217
|
const [open, setOpen] = React.useState(false);
|
|
183
218
|
const selected = clients.find((c) => c.id === value);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
219
|
+
|
|
220
|
+
const filtered = onSearch
|
|
221
|
+
? clients
|
|
222
|
+
: clients.filter((c) => {
|
|
223
|
+
const q = query.toLowerCase();
|
|
224
|
+
return (
|
|
225
|
+
c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q)
|
|
226
|
+
);
|
|
227
|
+
});
|
|
190
228
|
|
|
191
229
|
return (
|
|
192
230
|
<div className="relative">
|
|
193
231
|
<Input
|
|
194
232
|
value={selected ? selected.name : query}
|
|
195
233
|
onChange={(e) => {
|
|
196
|
-
|
|
234
|
+
const v = e.target.value;
|
|
235
|
+
setQuery(v);
|
|
197
236
|
if (selected) onValueChange(undefined);
|
|
198
|
-
setOpen(
|
|
237
|
+
setOpen(v.length > 0);
|
|
238
|
+
onSearch?.(v);
|
|
199
239
|
}}
|
|
200
240
|
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
|
201
241
|
placeholder="Search by name or email…"
|
|
202
242
|
autoComplete="off"
|
|
203
243
|
/>
|
|
204
|
-
{open && (filtered.length > 0 || query.length > 0) && (
|
|
205
|
-
<div className="absolute z-50 mt-1 w-full border border-border bg-popover shadow-md">
|
|
206
|
-
{
|
|
244
|
+
{open && (filtered.length > 0 || query.length > 0 || isSearching) && (
|
|
245
|
+
<div className="absolute z-50 mt-1 max-h-64 w-full overflow-y-auto border border-border bg-popover shadow-md">
|
|
246
|
+
{isSearching ? (
|
|
247
|
+
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
248
|
+
Searching...
|
|
249
|
+
</p>
|
|
250
|
+
) : filtered.length === 0 ? (
|
|
207
251
|
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
208
252
|
No clients found.
|
|
209
253
|
</p>
|
|
210
254
|
) : (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
255
|
+
<>
|
|
256
|
+
{filtered.map((c) => (
|
|
257
|
+
<Button
|
|
258
|
+
key={c.id}
|
|
259
|
+
type="button"
|
|
260
|
+
variant="ghost"
|
|
261
|
+
className="h-auto w-full flex-col items-start gap-0.5 px-3 py-2 text-left hover:bg-primary/5"
|
|
262
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
263
|
+
onClick={() => {
|
|
264
|
+
onValueChange(c.id);
|
|
265
|
+
setQuery("");
|
|
266
|
+
setOpen(false);
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
<span className="text-sm font-medium">{c.name}</span>
|
|
270
|
+
<span className="text-xs text-muted-foreground">
|
|
271
|
+
{c.email}
|
|
272
|
+
</span>
|
|
273
|
+
</Button>
|
|
274
|
+
))}
|
|
275
|
+
{hasMore && onLoadMore && (
|
|
276
|
+
<Button
|
|
277
|
+
type="button"
|
|
278
|
+
variant="ghost"
|
|
279
|
+
size="sm"
|
|
280
|
+
className="w-full text-sm text-primary"
|
|
281
|
+
disabled={isLoadingMore}
|
|
282
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
283
|
+
onClick={onLoadMore}
|
|
284
|
+
>
|
|
285
|
+
{isLoadingMore ? "Loading..." : "Load more"}
|
|
286
|
+
</Button>
|
|
287
|
+
)}
|
|
288
|
+
</>
|
|
228
289
|
)}
|
|
229
290
|
</div>
|
|
230
291
|
)}
|
|
@@ -277,6 +338,36 @@ const CLIENT_FORMAT_OPTIONS: FormatOption[] = [
|
|
|
277
338
|
},
|
|
278
339
|
];
|
|
279
340
|
|
|
341
|
+
function getFormatOptions(
|
|
342
|
+
platform?: "google-meet" | "microsoft-teams" | "any",
|
|
343
|
+
): FormatOption[] {
|
|
344
|
+
const call: FormatOption = {
|
|
345
|
+
value: "call",
|
|
346
|
+
label: "Call",
|
|
347
|
+
icon: <Phone className="h-4 w-4" />,
|
|
348
|
+
};
|
|
349
|
+
const googleMeet: FormatOption = {
|
|
350
|
+
value: "google-meet",
|
|
351
|
+
label: "Google Meet",
|
|
352
|
+
icon: <Video className="h-4 w-4" />,
|
|
353
|
+
};
|
|
354
|
+
const msTeams: FormatOption = {
|
|
355
|
+
value: "microsoft-teams",
|
|
356
|
+
label: "MS Teams",
|
|
357
|
+
icon: <Users className="h-4 w-4" />,
|
|
358
|
+
};
|
|
359
|
+
const offline: FormatOption = {
|
|
360
|
+
value: "offline",
|
|
361
|
+
label: "Offline Meeting",
|
|
362
|
+
icon: <MapPin className="h-4 w-4" />,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
if (platform === "google-meet") return [call, googleMeet, offline];
|
|
366
|
+
if (platform === "microsoft-teams") return [call, msTeams, offline];
|
|
367
|
+
if (platform === "any") return [call, googleMeet, msTeams, offline];
|
|
368
|
+
return [call, offline];
|
|
369
|
+
}
|
|
370
|
+
|
|
280
371
|
function MeetingFormatSection({
|
|
281
372
|
format,
|
|
282
373
|
onFormatChange,
|
|
@@ -458,6 +549,11 @@ export function AppointmentBookDialog({
|
|
|
458
549
|
open,
|
|
459
550
|
onOpenChange,
|
|
460
551
|
clients = [],
|
|
552
|
+
onSearchClients,
|
|
553
|
+
isSearchingClients,
|
|
554
|
+
hasMoreClients,
|
|
555
|
+
onLoadMoreClients,
|
|
556
|
+
isLoadingMoreClients,
|
|
461
557
|
meetingTypes = [],
|
|
462
558
|
amSlots,
|
|
463
559
|
pmSlots,
|
|
@@ -468,10 +564,11 @@ export function AppointmentBookDialog({
|
|
|
468
564
|
advisorInfo,
|
|
469
565
|
initialClientId,
|
|
470
566
|
defaultMeetingFormat,
|
|
567
|
+
onlinePlatform,
|
|
471
568
|
guestInfo,
|
|
472
569
|
onBook,
|
|
473
570
|
}: AppointmentBookDialogProps) {
|
|
474
|
-
const isClientMode = clients.length === 0;
|
|
571
|
+
const isClientMode = clients.length === 0 && !onSearchClients;
|
|
475
572
|
// Guest form is shown in client mode when at least one identity field is missing.
|
|
476
573
|
const showGuestForm = isClientMode && !(guestInfo?.name && guestInfo?.email);
|
|
477
574
|
|
|
@@ -620,6 +717,11 @@ export function AppointmentBookDialog({
|
|
|
620
717
|
clients={clients}
|
|
621
718
|
value={clientId}
|
|
622
719
|
onValueChange={setClientId}
|
|
720
|
+
onSearch={onSearchClients}
|
|
721
|
+
isSearching={isSearchingClients}
|
|
722
|
+
hasMore={hasMoreClients}
|
|
723
|
+
onLoadMore={onLoadMoreClients}
|
|
724
|
+
isLoadingMore={isLoadingMoreClients}
|
|
623
725
|
/>
|
|
624
726
|
</div>
|
|
625
727
|
)}
|
|
@@ -687,26 +789,21 @@ export function AppointmentBookDialog({
|
|
|
687
789
|
</>
|
|
688
790
|
)}
|
|
689
791
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
<
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
isClientMode ? CLIENT_FORMAT_OPTIONS : FORMAT_OPTIONS
|
|
706
|
-
}
|
|
707
|
-
/>
|
|
708
|
-
</div>
|
|
709
|
-
)}
|
|
792
|
+
<div className="flex flex-col gap-1.5">
|
|
793
|
+
<Label>Meeting format</Label>
|
|
794
|
+
<MeetingFormatSection
|
|
795
|
+
format={meetingFormat}
|
|
796
|
+
onFormatChange={setMeetingFormat}
|
|
797
|
+
offlineLocation={offlineLocation}
|
|
798
|
+
onOfflineLocationChange={setOfflineLocation}
|
|
799
|
+
customAddress={customAddress}
|
|
800
|
+
onCustomAddressChange={setCustomAddress}
|
|
801
|
+
advisorOfficeAddress={advisorOfficeAddress}
|
|
802
|
+
clientHomeAddress={clientHomeAddress}
|
|
803
|
+
isClientMode={isClientMode}
|
|
804
|
+
formatOptions={getFormatOptions(onlinePlatform)}
|
|
805
|
+
/>
|
|
806
|
+
</div>
|
|
710
807
|
|
|
711
808
|
{/* Phone selection — advisor mode, Call format, client has phones */}
|
|
712
809
|
{!isClientMode &&
|
|
@@ -82,6 +82,18 @@ export interface AppointmentDetailSheetProps {
|
|
|
82
82
|
clientProfile?: AppointmentClientProfile;
|
|
83
83
|
amSlots: AppointmentTimeSlot[];
|
|
84
84
|
pmSlots: AppointmentTimeSlot[];
|
|
85
|
+
/**
|
|
86
|
+
* Fired when the user selects a different date in the reschedule dialog.
|
|
87
|
+
* Use this to fetch fresh `amSlots`/`pmSlots` for the new date.
|
|
88
|
+
*/
|
|
89
|
+
onDateChange?: (date: Date) => void;
|
|
90
|
+
/**
|
|
91
|
+
* Advisor's weekly availability schedule. Days with `enabled: false` are
|
|
92
|
+
* disabled in the reschedule date picker.
|
|
93
|
+
*/
|
|
94
|
+
schedule?: { day: string; enabled: boolean }[];
|
|
95
|
+
/** True while slots are being fetched for the selected date. */
|
|
96
|
+
isLoadingSlots?: boolean;
|
|
85
97
|
onAccept?: (id: string) => void;
|
|
86
98
|
onDecline?: (id: string) => void;
|
|
87
99
|
onReschedule?: (
|
|
@@ -167,6 +179,9 @@ export function AppointmentDetailSheet({
|
|
|
167
179
|
clientProfile,
|
|
168
180
|
amSlots,
|
|
169
181
|
pmSlots,
|
|
182
|
+
onDateChange,
|
|
183
|
+
schedule,
|
|
184
|
+
isLoadingSlots,
|
|
170
185
|
onAccept,
|
|
171
186
|
onDecline,
|
|
172
187
|
onReschedule,
|
|
@@ -440,6 +455,9 @@ export function AppointmentDetailSheet({
|
|
|
440
455
|
currentDate={appointment.date}
|
|
441
456
|
currentTimeStart={appointment.timeStart}
|
|
442
457
|
currentTimeEnd={appointment.timeEnd}
|
|
458
|
+
onDateChange={onDateChange}
|
|
459
|
+
schedule={schedule}
|
|
460
|
+
isLoadingSlots={isLoadingSlots}
|
|
443
461
|
onReschedule={(date, slot, note) => {
|
|
444
462
|
onReschedule?.(appointment.id, date, slot, note);
|
|
445
463
|
setRescheduleOpen(false);
|