@tuturuuu/ui 0.38.0 → 0.39.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 (93) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +6 -6
  3. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  4. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  5. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  6. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  7. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  8. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  9. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  10. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  11. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  12. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  13. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  14. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  15. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  16. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  17. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  18. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  20. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  21. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  22. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  23. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  24. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  25. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  26. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  27. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  28. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  29. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  30. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  31. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  32. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  33. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  34. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  35. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  36. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  37. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  38. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  39. package/src/components/ui/finance/invoices/types.ts +3 -3
  40. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  41. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  42. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  43. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  44. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  45. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  46. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  47. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  48. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  49. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  50. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  51. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
  52. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  53. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  54. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  55. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  56. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  57. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  58. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  59. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  60. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  61. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  62. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  63. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  64. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  65. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  66. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  67. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  68. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  69. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
  70. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
  71. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  72. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  73. package/src/components/ui/text-editor/editor.tsx +3 -0
  74. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  75. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  76. package/src/components/ui/text-editor/tool-bar.tsx +18 -74
  77. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  78. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  79. package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
  80. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
  81. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  82. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  83. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
  84. package/src/hooks/calendar-connection-types.ts +49 -0
  85. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  86. package/src/hooks/calendar-event-write-payload.ts +33 -0
  87. package/src/hooks/collaboration-document-hydration.ts +35 -0
  88. package/src/hooks/supabase-provider.ts +39 -41
  89. package/src/hooks/use-calendar-sync.tsx +41 -62
  90. package/src/hooks/use-calendar.tsx +24 -38
  91. package/src/hooks/use-local-storage.ts +9 -11
  92. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  93. package/src/hooks/use-yjs-collaboration.ts +23 -0
@@ -1,1559 +1,31 @@
1
1
  'use client';
2
-
3
- import { zodResolver } from '@hookform/resolvers/zod';
4
- import { useQuery } from '@tanstack/react-query';
5
- import { calendarEventsSchema } from '@tuturuuu/ai/calendar/events';
6
- import { useObject } from '@tuturuuu/ai/object/core';
7
- import {
8
- AlertCircle,
9
- Brain,
10
- Calendar as CalendarIcon,
11
- Check,
12
- ChevronLeft,
13
- ChevronRight,
14
- Clock,
15
- FileText,
16
- Image as ImageIcon,
17
- Info,
18
- Loader2,
19
- Lock,
20
- MapPin,
21
- Mic,
22
- Sparkles,
23
- StopCircle,
24
- Trash2,
25
- Unlock,
26
- X,
27
- } from '@tuturuuu/icons';
28
- import { getWorkspaceCalendarDefaultSource } from '@tuturuuu/internal-api';
29
- import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
30
- import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
31
- import { Badge } from '@tuturuuu/ui/badge';
32
- import { Button } from '@tuturuuu/ui/button';
33
- import { Dialog, DialogContent } from '@tuturuuu/ui/dialog';
34
- import {
35
- Form,
36
- FormControl,
37
- FormDescription,
38
- FormField,
39
- FormItem,
40
- FormLabel,
41
- FormMessage,
42
- } from '@tuturuuu/ui/form';
43
- import { newCalendarCreationRequestId } from '@tuturuuu/ui/hooks/calendar-creation-request';
44
- import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
45
- import { useForm } from '@tuturuuu/ui/hooks/use-form';
46
- import { notifySave } from '@tuturuuu/ui/save-notification';
47
- import { ScrollArea } from '@tuturuuu/ui/scroll-area';
48
- import {
49
- Select,
50
- SelectContent,
51
- SelectItem,
52
- SelectTrigger,
53
- SelectValue,
54
- } from '@tuturuuu/ui/select';
55
- import { Separator } from '@tuturuuu/ui/separator';
56
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
57
- import {
58
- createAllDayEvent,
59
- isAllDayEvent,
60
- } from '@tuturuuu/utils/calendar-utils';
61
- import { getEventStyles } from '@tuturuuu/utils/color-helper';
62
- import { cn } from '@tuturuuu/utils/format';
63
- import { format } from 'date-fns';
64
- import dayjs from 'dayjs';
65
- import ts from 'dayjs/plugin/timezone';
66
- import utc from 'dayjs/plugin/utc';
67
- import {
68
- useCallback,
69
- useEffect,
70
- useEffectEvent,
71
- useMemo,
72
- useRef,
73
- useState,
74
- } from 'react';
75
- import { z } from 'zod';
76
- import { useCalendarDraftSave } from './use-calendar-draft-save';
77
- import { Alert, AlertDescription, AlertTitle } from '../../alert';
78
- import { AutosizeTextarea } from '../../custom/autosize-textarea';
79
- import {
80
- COLOR_OPTIONS,
81
- DateError,
82
- EventDateTimePicker,
83
- EventDescriptionInput,
84
- EventLocationInput,
85
- EventTitleInput,
86
- EventToggleSwitch,
87
- OverlapWarning,
88
- } from './event-form-components';
89
- import { EventModalHeader } from './event-modal-header';
90
- import { eventEndPickerBounds } from './event-picker-bounds';
91
- import { eventModalDraft, eventModalSavePayload } from './event-save-payload';
92
- import {
93
- eventSourceChanged,
94
- selectedEventSource,
95
- sourceInputFromOption,
96
- } from './event-source-selection';
97
- import { ProviderColorPicker } from './provider-color-picker';
98
- import {
99
- prepareCalendarEventDrafts,
100
- saveCalendarEventDrafts,
101
- } from './save-calendar-event-drafts';
102
- import { useCalendarSettings } from './settings/settings-context';
103
- import { useEventDraftSession } from './use-event-draft-session';
104
-
105
- dayjs.extend(ts);
106
- dayjs.extend(utc);
107
-
108
- const AIFormSchema = z.object({
109
- prompt: z.string().min(1, 'Please enter a prompt to generate an event'),
110
- timezone: z
111
- .string()
112
- .default(() => Intl.DateTimeFormat().resolvedOptions().timeZone),
113
- smart_scheduling: z.boolean().default(true),
114
- });
115
-
2
+ import { isProviderRecurrenceReadonly } from '@tuturuuu/utils/calendar-provider-readonly';
3
+ import { useCalendar } from '../../../../hooks/use-calendar';
4
+ import { LegacyEventModal } from './legacy-event-modal';
5
+ import { NativeRecurrenceDialog } from './native-recurrence-dialog';
6
+ import { ProviderReadonlyDialog } from './provider-readonly-dialog';
116
7
  export function EventModal() {
117
- const toast = notifySave;
118
- const startPickerRef = useRef<HTMLButtonElement>(null);
119
- const endPickerRef = useRef<HTMLButtonElement>(null);
120
-
121
- const {
122
- activeEvent,
123
- isModalOpen,
124
- closeModal,
125
- addEvent,
126
- updateEvent,
127
- deleteEvent,
128
- getEvents,
129
- defaultNewEventTab,
130
- } = useCalendar();
131
- const { settings } = useCalendarSettings();
132
-
133
- const tz = settings?.timezone?.timezone;
134
-
135
- const [event, setEvent] = useState<Partial<CalendarEvent>>({
136
- title: '',
137
- description: '',
138
- start_at: new Date().toISOString(),
139
- end_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // Default to 1 hour
140
- color: 'BLUE',
141
- location: '',
142
- locked: false,
143
- });
144
- const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
145
-
146
- const wsId = activeEvent?.ws_id || event.ws_id;
147
- const { data: sourceData } = useQuery({
148
- queryKey: ['calendar-default-source', wsId],
149
- enabled: !!wsId,
150
- queryFn: () => getWorkspaceCalendarDefaultSource(wsId as string),
151
- staleTime: 30_000,
152
- });
153
- const sourceOptions = useMemo(
154
- () => sourceData?.options ?? [],
155
- [sourceData?.options]
156
- );
157
- const selectedSourceOption = selectedEventSource(
158
- sourceOptions,
159
- event,
160
- selectedSourceId,
161
- sourceData?.defaultSource
162
- );
163
-
164
- // State for AI event generation
165
- const [generatedEvents, setGeneratedEvents] =
166
- useState<Partial<CalendarEvent>[]>();
167
- const [currentEventIndex, setCurrentEventIndex] = useState(0);
168
- const [userTimezone] = useState(
169
- Intl.DateTimeFormat().resolvedOptions().timeZone
170
- );
171
-
172
- const generatedEvent = generatedEvents?.[currentEventIndex];
173
-
174
- // Determine if we're editing an existing event
175
- const isEditing = !!(activeEvent?.id && activeEvent.id !== 'new');
176
-
177
- const [activeTab, setActiveTab] = useState<'manual' | 'ai' | 'preview'>(
178
- isEditing ? 'manual' : defaultNewEventTab // Default tab for new events
179
- );
180
- const [isAllDay, setIsAllDay] = useState(false);
181
- const [isSaving, setIsSaving] = useState(false);
182
- const [isDeleting, setIsDeleting] = useState(false);
183
- const [dateError, setDateError] = useState<string | null>(null);
184
- const [overlappingEvents, setOverlappingEvents] = useState<CalendarEvent[]>(
185
- []
186
- );
187
- const [showOverlapWarning, setShowOverlapWarning] = useState(false);
188
- const [aiSuggestions, setAiSuggestions] = useState<string[]>([]);
189
-
190
- // Store previous time values for toggling all-day
191
- const [prevTimes, setPrevTimes] = useState<{
192
- timed: { start: string | null; end: string | null };
193
- allday: { start: string | null; end: string | null };
194
- }>({ timed: { start: null, end: null }, allday: { start: null, end: null } });
195
-
196
- // AI form
197
- const aiForm = useForm({
198
- resolver: zodResolver(AIFormSchema),
199
- defaultValues: {
200
- prompt: '',
201
- timezone: userTimezone,
202
- smart_scheduling: true,
203
- },
204
- });
205
-
206
- const { object, submit, error, isLoading } = useObject({
207
- api: '/api/v1/calendar/events/generate',
208
- schema: calendarEventsSchema,
209
- });
210
-
211
- const checkForOverlaps = useCallback(
212
- (eventToCheck: Partial<CalendarEvent>) => {
213
- if (!eventToCheck.start_at || !eventToCheck.end_at) return;
214
-
215
- const allEvents = getEvents();
216
- const eventStart = new Date(eventToCheck.start_at);
217
- const eventEnd = new Date(eventToCheck.end_at);
218
-
219
- // Find events that overlap with this event
220
- const overlaps = allEvents.filter((existingEvent) => {
221
- // Skip comparing with the current event being edited
222
- if (existingEvent.id === activeEvent?.id) return false;
223
-
224
- const existingStart = new Date(existingEvent.start_at);
225
- const existingEnd = new Date(existingEvent.end_at);
226
-
227
- // Check if the events are on the same day
228
- const isSameDay =
229
- existingStart.getDate() === eventStart.getDate() &&
230
- existingStart.getMonth() === eventStart.getMonth() &&
231
- existingStart.getFullYear() === eventStart.getFullYear();
232
-
233
- if (!isSameDay) return false;
234
-
235
- // Check for time overlap
236
- return !(existingEnd <= eventStart || existingStart >= eventEnd);
237
- });
238
-
239
- setOverlappingEvents(overlaps);
240
- setShowOverlapWarning(overlaps.length > 0);
241
- },
242
- [activeEvent, getEvents]
243
- );
244
-
245
- const receiveGeneratedEvents = useEffectEvent(() => {
246
- const processedEvents = (object?.events || []) as Partial<CalendarEvent>[];
247
- setGeneratedEvents(prepareCalendarEventDrafts(processedEvents));
248
- setCurrentEventIndex(0);
249
- const firstEvent = processedEvents[0];
250
- if (
251
- firstEvent?.start_at &&
252
- firstEvent.end_at &&
253
- aiForm.getValues().smart_scheduling
254
- ) {
255
- checkForOverlaps(firstEvent);
256
- }
257
- setActiveTab('preview');
258
- });
259
- useEffect(() => {
260
- if (object?.events && !isLoading) receiveGeneratedEvents();
261
- }, [object, isLoading]);
262
-
263
- // Initialize once per dialog session; live calendar refreshes must not erase edits.
264
- useEventDraftSession({
265
- isOpen: isModalOpen,
266
- eventId: activeEvent?.id,
267
- workspaceId: activeEvent?.ws_id,
268
- initialize: () => {
269
- if (activeEvent) {
270
- const cleanEventData = eventModalDraft(activeEvent);
271
-
272
- setEvent(cleanEventData);
273
- setSelectedSourceId(
274
- selectedEventSource(
275
- sourceOptions,
276
- cleanEventData,
277
- null,
278
- sourceData?.defaultSource
279
- )?.id ?? null
280
- );
281
-
282
- if (activeEvent.id !== 'new') {
283
- setIsAllDay(isAllDayEvent(cleanEventData as CalendarEvent));
284
- } else {
285
- setIsAllDay(false);
286
- }
287
-
288
- // Check for overlapping events
289
- checkForOverlaps(cleanEventData);
290
-
291
- setActiveTab(isEditing ? 'manual' : defaultNewEventTab);
292
- } else {
293
- const now = new Date();
294
- const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
295
-
296
- const newEvent = {
297
- requestId: newCalendarCreationRequestId(),
298
- title: '',
299
- description: '',
300
- start_at: now.toISOString(),
301
- end_at: oneHourLater.toISOString(),
302
- color: 'BLUE' as SupportedColor,
303
- location: '',
304
- locked: false,
305
- ws_id: '',
306
- };
307
-
308
- setEvent(newEvent);
309
- setSelectedSourceId(sourceData?.defaultSource?.id ?? null);
310
- setIsAllDay(false);
311
-
312
- aiForm.reset();
313
- setGeneratedEvents([]);
314
- }
315
-
316
- setDateError(null);
317
- },
318
- });
319
-
320
- const draftSave = useCalendarDraftSave({
321
- draft: event,
322
- original: activeEvent,
323
- buildPayload: () =>
324
- eventModalSavePayload(
325
- event,
326
- activeEvent,
327
- sourceInputFromOption(selectedSourceOption),
328
- eventSourceChanged(sourceOptions, activeEvent ?? {}, selectedSourceId)
329
- ),
330
- addEvent,
331
- updateEvent,
332
- closeModal,
333
- setIsSaving,
334
- isSaving,
335
- source: selectedSourceOption,
336
- wsId,
337
- onError: () =>
338
- toast({
339
- title: 'Error',
340
- description: 'Failed to save or sync event. Please try again.',
341
- variant: 'destructive',
342
- }),
343
- });
344
- const handleManualSave = async () => {
345
- if (!event.start_at || !event.end_at) return;
346
- if (new Date(event.end_at) <= new Date(event.start_at)) {
347
- setDateError('End date must be after start date');
348
- return;
349
- }
350
- setDateError(null);
351
- await draftSave.save();
352
- };
353
-
354
- // Handle AI event generation
355
- const handleGenerateEvent = async (values: z.infer<typeof AIFormSchema>) => {
356
- try {
357
- // Include timezone in the prompt for accurate time conversion
358
- const promptWithTimezone = `${values.prompt}\n\nUser timezone: ${values.timezone}`;
359
-
360
- // Get existing events for smart scheduling
361
- const existingEvents = values.smart_scheduling ? getEvents() : [];
362
-
363
- // Generate helpful suggestions based on the prompt
364
- const suggestions = [
365
- 'Consider adding a buffer time before/after these events',
366
- 'These events might benefit from reminder notifications',
367
- 'Based on your schedule, early morning might be better for focus',
368
- 'Consider adding meeting agenda or preparation notes',
369
- ];
370
-
371
- // Add category-based suggestions
372
- if (settings?.categoryColors?.categories.length > 0) {
373
- const categoryNames = settings.categoryColors.categories
374
- .map((cat) => cat.name)
375
- .join(', ');
376
- suggestions.push(
377
- `You have categories set up: ${categoryNames}. Events will be colored based on these categories.`
378
- );
379
- }
380
-
381
- setAiSuggestions(suggestions.slice(0, 3)); // Show up to 3 relevant suggestions
382
-
383
- // Format categories for the AI request
384
- const formattedCategories = settings?.categoryColors?.categories.map(
385
- (category) => ({
386
- name: category.name,
387
- color: category.color.toLowerCase(),
388
- })
389
- );
390
-
391
- submit({
392
- prompt: promptWithTimezone,
393
- current_time: new Date().toISOString(),
394
- smart_scheduling: values.smart_scheduling,
395
- existing_events: values.smart_scheduling ? existingEvents : undefined,
396
- categories: formattedCategories,
397
- });
398
- } catch (_) {
399
- toast({
400
- title: 'Error generating events',
401
- description: 'Please try again with a different prompt',
402
- variant: 'destructive',
403
- });
404
- }
405
- };
406
-
407
- // Handle AI event save
408
- const handleAISave = async () => {
409
- if (generatedEvents?.length === 0) return;
410
-
411
- setIsSaving(true);
412
- try {
413
- const eventsToSave = generatedEvents;
414
- const { savedEvents, failedEvents } = await saveCalendarEventDrafts(
415
- eventsToSave ?? [],
416
- addEvent,
417
- sourceInputFromOption(selectedSourceOption)
418
- );
419
- setGeneratedEvents(failedEvents);
420
- setCurrentEventIndex(0);
421
-
422
- // Show success notification
423
- if (savedEvents.length > 0) {
424
- toast({
425
- title: 'Success',
426
- description: `${savedEvents.length}/${eventsToSave?.length || 0} event${savedEvents.length > 1 ? 's' : ''} saved`,
427
- });
428
- if (failedEvents.length === 0) closeModal();
429
- }
430
-
431
- // If there are failed events, show an error notification
432
- if (failedEvents.length > 0) {
433
- toast({
434
- title: 'Warning',
435
- description: `${failedEvents.length} event${failedEvents.length > 1 ? 's' : ''} failed to save`,
436
- variant: 'destructive',
437
- });
438
- }
439
- } catch (_) {
440
- toast({
441
- title: 'Error',
442
- description: 'Failed to save or sync AI-generated events.',
443
- variant: 'destructive',
444
- });
445
- } finally {
446
- setIsSaving(false);
447
- }
448
- };
449
-
450
- // Handle event deletion
451
- const handleDelete = async () => {
452
- if (isRecording) {
453
- stopRecording();
454
- }
455
- if (!activeEvent?.id) return;
456
-
457
- setIsDeleting(true);
458
- try {
459
- // For split events, use the original ID
460
- const originalId = activeEvent.id;
461
-
462
- // Make sure originalId is not undefined
463
- if (originalId) {
464
- await deleteEvent(originalId);
465
- }
466
- closeModal();
467
- } catch (_) {
468
- toast({
469
- title: 'Error',
470
- description: 'Failed to delete event. Please try again.',
471
- variant: 'destructive',
472
- });
473
- } finally {
474
- setIsDeleting(false);
475
- }
476
- };
477
-
478
- // Handle start date change
479
- const handleStartDateChange = (date: Date | undefined) => {
480
- if (!date) return;
481
-
482
- setEvent((prev) => {
483
- const newStartDate = tz === 'auto' ? dayjs(date) : dayjs(date).tz(tz);
484
- const endDate =
485
- tz === 'auto'
486
- ? dayjs(prev.end_at || '')
487
- : dayjs(prev.end_at || '').tz(tz);
488
- const newEvent = { ...prev, start_at: date.toISOString() };
489
-
490
- if (isAllDay) {
491
- // For all-day, keep end_at as is if it's after or equal to start_at + 1 day, otherwise set to start + 1 day
492
- newEvent.start_at = newStartDate.startOf('day').toISOString();
493
- let newEnd = dayjs(prev.end_at).isValid()
494
- ? dayjs(prev.end_at)
495
- : newStartDate.startOf('day').add(1, 'day');
496
- if (!newEnd.isAfter(newStartDate.startOf('day'))) {
497
- newEnd = newStartDate.startOf('day').add(1, 'day');
498
- }
499
- newEvent.end_at = newEnd.toISOString();
500
- } else {
501
- // If end time is before or equal to new start time, set end time to 1 hour after start
502
- if (!endDate.isAfter(newStartDate)) {
503
- const plusOneHour = newStartDate.add(1, 'hour');
504
- if (plusOneHour.date() !== newStartDate.date()) {
505
- newEvent.end_at = plusOneHour.toISOString();
506
- } else {
507
- newEvent.end_at = plusOneHour.toISOString();
508
- }
509
- }
510
- }
511
-
512
- // Only show error if end <= start
513
- if (
514
- dayjs(newEvent.end_at).isSame(dayjs(newEvent.start_at)) ||
515
- dayjs(newEvent.end_at).isBefore(dayjs(newEvent.start_at))
516
- ) {
517
- setDateError('End time must be after start time.');
518
- } else {
519
- setDateError(null);
520
- }
521
- return newEvent;
522
- });
523
- };
524
-
525
- // Handle end date change
526
- const handleEndDateChange = (date: Date | undefined) => {
527
- if (!date) return;
528
-
529
- setEvent((prev) => {
530
- let newEndDate = tz === 'auto' ? dayjs(date) : dayjs(date).tz(tz);
531
- const startDate =
532
- tz === 'auto'
533
- ? dayjs(prev.start_at || '')
534
- : dayjs(prev.start_at || '').tz(tz);
535
- const newEvent = { ...prev };
536
-
537
- if (isAllDay) {
538
- // For all-day, set end_at to the start of the day after the selected end date
539
- let newEnd = newEndDate.startOf('day').add(1, 'day');
540
- // Ensure end is not before or equal to start
541
- if (!newEnd.isAfter(startDate.startOf('day'))) {
542
- newEnd = startDate.startOf('day').add(1, 'day');
543
- }
544
- newEvent.end_at = newEnd.toISOString();
545
- // Don't change start_at here
546
- } else {
547
- // If end is not after start, increment end date by one day and set the entered time
548
- if (!newEndDate.isAfter(startDate)) {
549
- // Use the time from the user's input, but increment the date by one day
550
- newEndDate = newEndDate.add(1, 'day');
551
- }
552
- // If end is after start, accept as is
553
- newEvent.end_at = newEndDate.toISOString();
554
- }
555
-
556
- if (!dayjs(newEvent.end_at).isAfter(dayjs(newEvent.start_at))) {
557
- setDateError('End time must be after start time.');
558
- } else {
559
- setDateError(null);
560
- }
561
-
562
- return newEvent;
563
- });
564
- };
565
-
566
- // Handle all-day toggle
567
- const handleAllDayChange = (checked: boolean) => {
568
- setEvent((prev) => {
569
- const startDate =
570
- tz === 'auto' ? dayjs(prev.start_at) : dayjs(prev.start_at).tz(tz);
571
- // Backup previous times before updating
572
- const timedBackup = prevTimes.timed;
573
- const alldayBackup = prevTimes.allday;
574
- if (checked) {
575
- setPrevTimes((old) => ({
576
- ...old,
577
- timed: { start: prev.start_at || null, end: prev.end_at || null },
578
- }));
579
- // Restore previous all-day range if it exists
580
- if (alldayBackup.start && alldayBackup.end) {
581
- return {
582
- ...prev,
583
- start_at: alldayBackup.start,
584
- end_at: alldayBackup.end,
585
- };
586
- }
587
- // Use the new createAllDayEvent helper for proper timezone handling
588
- const { start_at, end_at } = createAllDayEvent(
589
- startDate.toDate(),
590
- tz,
591
- 1 // 1 day duration
592
- );
593
- return {
594
- ...prev,
595
- start_at,
596
- end_at,
597
- };
598
- } else {
599
- setPrevTimes((old) => ({
600
- ...old,
601
- allday: { start: prev.start_at || null, end: prev.end_at || null },
602
- }));
603
- // Restore previous timed range if it exists
604
- if (timedBackup.start && timedBackup.end) {
605
- return {
606
- ...prev,
607
- start_at: timedBackup.start,
608
- end_at: timedBackup.end,
609
- };
610
- }
611
- const newStart =
612
- tz === 'auto'
613
- ? dayjs().startOf('hour')
614
- : dayjs().startOf('hour').tz(tz);
615
- const newEnd = newStart.add(1, 'hour');
616
- return {
617
- ...prev,
618
- start_at: newStart.toISOString(),
619
- end_at: newEnd.toISOString(),
620
- };
621
- }
622
- });
623
- setIsAllDay((prev) => !prev);
624
- };
625
-
626
- const [isRecording, setIsRecording] = useState(false);
627
- const [isProcessingAudio, setIsProcessingAudio] = useState(false);
628
- const [isProcessingImage, setIsProcessingImage] = useState(false);
629
- const mediaRecorder = useRef<MediaRecorder | null>(null);
630
- const mediaStream = useRef<MediaStream | null>(null);
631
- const audioChunks = useRef<Blob[]>([]);
632
- const fileInputRef = useRef<HTMLInputElement>(null);
633
-
634
- const startRecording = async () => {
635
- setIsRecording(true);
636
- try {
637
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
638
- mediaStream.current = stream;
639
- mediaRecorder.current = new MediaRecorder(stream, {
640
- mimeType: 'audio/webm',
641
- });
642
-
643
- mediaRecorder.current.ondataavailable = (event) => {
644
- if (event.data.size > 0) {
645
- audioChunks.current.push(event.data);
646
- }
647
- };
648
-
649
- mediaRecorder.current.onstop = async () => {
650
- if (audioChunks.current.length === 0) {
651
- setIsRecording(false);
652
- setIsProcessingAudio(false);
653
- return;
654
- }
655
-
656
- const audioBlob = new Blob(audioChunks.current, { type: 'audio/webm' });
657
- const reader = new FileReader();
658
-
659
- reader.readAsDataURL(audioBlob);
660
- reader.onloadend = async () => {
661
- const base64Audio = reader.result?.toString().split(',')[1];
662
-
663
- if (!base64Audio) {
664
- setIsProcessingAudio(false);
665
- return;
666
- }
667
-
668
- sendAudioToServer(base64Audio);
669
- };
670
-
671
- // Cleanup
672
- revokeMediaPermissions();
673
- audioChunks.current = [];
674
- };
675
-
676
- mediaRecorder.current.start();
677
- } catch (_) {
678
- setIsRecording(false);
679
- }
680
- };
681
-
682
- const triggerImageUpload = () => {
683
- fileInputRef.current?.click();
684
- };
685
-
686
- const stopRecording = () => {
687
- setIsRecording(false);
688
- setIsProcessingAudio(true);
689
- if (mediaRecorder.current && mediaRecorder.current.state !== 'inactive') {
690
- mediaRecorder.current.stop();
691
- } else {
692
- revokeMediaPermissions();
693
- setIsProcessingAudio(false);
694
- }
695
- };
696
-
697
- const revokeMediaPermissions = () => {
698
- if (mediaStream.current) {
699
- mediaStream.current.getTracks().forEach((track) => {
700
- track.stop();
701
- });
702
- mediaStream.current = null;
703
- }
704
- };
705
-
706
- const sendAudioToServer = async (base64Audio: string) => {
707
- try {
708
- const response = await fetch('/api/v1/calendar/audio', {
709
- method: 'POST',
710
- headers: { 'Content-Type': 'application/json' },
711
- body: JSON.stringify({ base64Audio }),
712
- });
713
-
714
- const result = await response.json();
715
- if (result.text) {
716
- aiForm.setValue('prompt', result.text);
717
- } else {
718
- toast({
719
- title: 'Transcription Error',
720
- description: result.error || 'Failed to transcribe audio',
721
- variant: 'destructive',
722
- });
723
- }
724
- } catch (_) {
725
- toast({
726
- title: 'API Error',
727
- description: 'Failed to process audio recording',
728
- variant: 'destructive',
729
- });
730
- } finally {
731
- setIsProcessingAudio(false);
732
- }
733
- };
734
-
735
- const handleUploadImage = async (
736
- event: React.ChangeEvent<HTMLInputElement>
737
- ) => {
738
- const file = event.target.files?.[0]; // image file from input
739
- if (!file) return;
740
-
741
- setIsProcessingImage(true);
742
- const reader = new FileReader();
743
- reader.readAsDataURL(file);
744
- reader.onloadend = async () => {
745
- const base64Image = reader.result?.toString().split(',')[1]; // convert to base64
746
- if (!base64Image) {
747
- setIsProcessingImage(false);
748
- return;
749
- }
750
-
751
- try {
752
- const response = await fetch('/api/v1/calendar/image', {
753
- // send to API
754
- method: 'POST',
755
- headers: { 'Content-Type': 'application/json' },
756
- body: JSON.stringify({ base64Image }),
757
- });
758
-
759
- const result = await response.json();
760
- if (result.text) {
761
- aiForm.setValue('prompt', result.text);
762
- } else {
763
- toast({
764
- title: 'Image Processing Error',
765
- description: result.error || 'Failed to extract text from image',
766
- variant: 'destructive',
767
- });
768
- }
769
- } catch (_) {
770
- toast({
771
- title: 'API Error',
772
- description: 'Failed to process uploaded image',
773
- variant: 'destructive',
774
- });
775
- } finally {
776
- setIsProcessingImage(false);
777
- if (fileInputRef.current) {
778
- fileInputRef.current.value = '';
779
- }
780
- }
781
- };
782
- };
783
-
784
- // Handle navigation between multiple events in preview
785
- const goToNextEvent = () => {
786
- if (currentEventIndex < (generatedEvents?.length || 0) - 1) {
787
- const nextIndex = currentEventIndex + 1;
788
- setCurrentEventIndex(nextIndex);
789
- if (aiForm.getValues().smart_scheduling) {
790
- const nextEvent = generatedEvents?.[nextIndex];
791
- if (nextEvent?.start_at && nextEvent.end_at) {
792
- checkForOverlaps(nextEvent as Partial<CalendarEvent>);
793
- }
794
- }
795
- }
796
- };
797
-
798
- const goToPreviousEvent = () => {
799
- if (currentEventIndex > 0) {
800
- const prevIndex = currentEventIndex - 1;
801
- setCurrentEventIndex(prevIndex);
802
- if (aiForm.getValues().smart_scheduling) {
803
- const prevEvent = generatedEvents?.[prevIndex];
804
- if (prevEvent?.start_at && prevEvent.end_at) {
805
- checkForOverlaps(prevEvent as Partial<CalendarEvent>);
806
- }
807
- }
808
- }
809
- };
810
-
811
- // function to open Google Maps with the entered address
812
- const openGoogleMaps = (address: string) => {
813
- if (address) {
814
- const encodedAddress = encodeURIComponent(address);
815
- window.open(
816
- `https://www.google.com/maps/search/?api=1&query=${encodedAddress}`,
817
- '_blank'
818
- );
819
- }
820
- };
821
-
822
- // Handle lock toggle
823
- const handleLockToggle = async (checked: boolean) => {
824
- if (!activeEvent?.id) return;
825
-
826
- setIsSaving(true);
827
- try {
828
- const originalId = activeEvent.id;
829
-
830
- const updatedEvent = await updateEvent(originalId, {
831
- ...event,
832
- locked: checked,
833
- });
834
-
835
- if (!updatedEvent) {
836
- throw new Error('Failed to update event lock status');
837
- }
838
-
839
- setEvent(updatedEvent);
840
- toast({
841
- title: 'Success',
842
- description: `Event ${checked ? 'locked' : 'unlocked'} successfully`,
843
- });
844
- } catch (_) {
845
- toast({
846
- title: 'Error',
847
- description: 'Failed to update event lock status',
848
- variant: 'destructive',
849
- });
850
- // Revert the toggle if update fails
851
- setEvent((prev) => ({ ...prev, locked: !checked }));
852
- } finally {
853
- setIsSaving(false);
854
- }
855
- };
856
-
8
+ const { activeEvent, isModalOpen, closeModal, readOnly } = useCalendar();
9
+ if (activeEvent && isProviderRecurrenceReadonly(activeEvent))
10
+ return (
11
+ <ProviderReadonlyDialog
12
+ event={activeEvent}
13
+ open={isModalOpen}
14
+ onOpenChange={(open) => {
15
+ if (!open) closeModal();
16
+ }}
17
+ />
18
+ );
19
+ if (!activeEvent?.seriesId) return <LegacyEventModal />;
857
20
  return (
858
- <Dialog open={isModalOpen} onOpenChange={(open) => !open && closeModal()}>
859
- <DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden p-0">
860
- <EventModalHeader event={event} isEditing={isEditing} />
861
-
862
- <Tabs
863
- value={activeTab}
864
- onValueChange={(value) => setActiveTab(value as 'manual' | 'ai')}
865
- className="flex h-[calc(90vh-140px)] flex-col"
866
- >
867
- <TabsList className="justify-start gap-2 bg-transparent px-6 pt-4 pb-0">
868
- <TabsTrigger
869
- value="manual"
870
- className="rounded-t-md rounded-b-none border-b-0 px-4 py-2 data-[state=active]:border data-[state=active]:border-b-0 data-[state=active]:bg-background data-[state=active]:shadow-sm"
871
- >
872
- <CalendarIcon className="mr-2 h-4 w-4" />
873
- Quick Entry
874
- </TabsTrigger>
875
- <TabsTrigger
876
- value="ai"
877
- className="rounded-t-md rounded-b-none border-b-0 px-4 py-2 data-[state=active]:border data-[state=active]:border-b-0 data-[state=active]:bg-background data-[state=active]:shadow-sm"
878
- disabled={!!isEditing}
879
- >
880
- <Sparkles className="mr-2 h-4 w-4" />
881
- AI Generate
882
- </TabsTrigger>
883
- </TabsList>
884
-
885
- <div className="flex-1 overflow-hidden border-t">
886
- {/* Manual Event Creation Tab */}
887
- <TabsContent
888
- value="manual"
889
- className="h-full p-0 focus-visible:outline-none focus-visible:ring-0 data-[state=active]:flex data-[state=active]:flex-col"
890
- style={{ display: activeTab === 'manual' ? 'flex' : 'none' }}
891
- >
892
- <div className="flex flex-1 flex-col overflow-hidden">
893
- <ScrollArea className="h-[calc(90vh-250px)] flex-1">
894
- <div className="space-y-3 p-4">
895
- {/* Title */}
896
- <EventTitleInput
897
- value={event.title || ''}
898
- onEnter={handleManualSave}
899
- onChange={(value) => setEvent({ ...event, title: value })}
900
- />
901
-
902
- <div className="space-y-2">
903
- <div className="flex items-center justify-between">
904
- <label
905
- htmlFor="event-source"
906
- className="font-medium text-sm"
907
- >
908
- Calendar source
909
- </label>
910
- {selectedSourceOption && (
911
- <Badge variant="secondary" className="capitalize">
912
- {selectedSourceOption.provider}
913
- </Badge>
914
- )}
915
- </div>
916
- <Select
917
- value={selectedSourceOption?.id}
918
- onValueChange={(value) => {
919
- setSelectedSourceId(value);
920
- setEvent((previous) => ({
921
- ...previous,
922
- providerColor: undefined,
923
- }));
924
- }}
925
- disabled={sourceOptions.length === 0 || isSaving}
926
- >
927
- <SelectTrigger id="event-source" className="w-full">
928
- <SelectValue placeholder="Choose calendar source" />
929
- </SelectTrigger>
930
- <SelectContent>
931
- {sourceOptions.map((option) => (
932
- <SelectItem key={option.id} value={option.id}>
933
- <div className="flex min-w-0 items-center gap-2">
934
- <span
935
- className="h-2.5 w-2.5 shrink-0 rounded-full"
936
- style={{
937
- backgroundColor: option.color ?? undefined,
938
- }}
939
- />
940
- <span className="truncate">{option.label}</span>
941
- {option.provider !== 'tuturuuu' &&
942
- option.accountEmail && (
943
- <span className="text-muted-foreground text-xs">
944
- {option.accountEmail}
945
- </span>
946
- )}
947
- </div>
948
- </SelectItem>
949
- ))}
950
- </SelectContent>
951
- </Select>
952
- </div>
953
-
954
- {/* Date and Time Selection */}
955
- <div className="space-y-2">
956
- <div className="flex items-center justify-between">
957
- <h3 className="font-medium text-sm">When</h3>
958
- <EventToggleSwitch
959
- id="all-day"
960
- label="All Day"
961
- checked={isAllDay}
962
- onChange={handleAllDayChange}
963
- />
964
- </div>
965
-
966
- <div className="grid gap-3">
967
- <EventDateTimePicker
968
- label="Start"
969
- value={new Date(event.start_at || new Date())}
970
- onChange={handleStartDateChange}
971
- showTimeSelect={!isAllDay}
972
- scrollIntoViewOnOpen={true}
973
- pickerButtonRef={startPickerRef}
974
- />
975
- <EventDateTimePicker
976
- label="End"
977
- value={(() => {
978
- // For all-day, display end_at - 1 day
979
- if (isAllDay && event.end_at) {
980
- const end = new Date(event.end_at);
981
- end.setDate(end.getDate() - 1);
982
- return end;
983
- }
984
- return new Date(event.end_at || new Date());
985
- })()}
986
- onChange={handleEndDateChange}
987
- showTimeSelect={!isAllDay}
988
- {...eventEndPickerBounds(
989
- event.start_at || new Date().toISOString(),
990
- tz,
991
- isAllDay
992
- )}
993
- scrollIntoViewOnOpen={true}
994
- pickerButtonRef={endPickerRef}
995
- />
996
- </div>
997
- </div>
998
-
999
- <Separator />
1000
-
1001
- {/* Location and Description */}
1002
- <div className="space-y-2">
1003
- <EventLocationInput
1004
- value={event.location || ''}
1005
- onChange={(value) =>
1006
- setEvent({ ...event, location: value })
1007
- }
1008
- />
1009
- <EventDescriptionInput
1010
- value={event.description || ''}
1011
- onChange={(value) =>
1012
- setEvent({ ...event, description: value })
1013
- }
1014
- />
1015
-
1016
- {/* Color and Options Row */}
1017
- <div className="flex items-end justify-between gap-4">
1018
- <ProviderColorPicker
1019
- wsId={wsId}
1020
- source={selectedSourceOption}
1021
- value={event.color || 'BLUE'}
1022
- metadata={event.scheduling_metadata}
1023
- choice={event.providerColor}
1024
- onNativeChange={(value) =>
1025
- setEvent({
1026
- ...event,
1027
- color: value,
1028
- providerColor: undefined,
1029
- })
1030
- }
1031
- onProviderChange={(providerColor) =>
1032
- setEvent({ ...event, providerColor })
1033
- }
1034
- />
1035
- <button
1036
- type="button"
1037
- onClick={() => {
1038
- const newLocked = !event.locked;
1039
- if (isEditing) {
1040
- handleLockToggle(newLocked);
1041
- } else {
1042
- setEvent((prev) => ({
1043
- ...prev,
1044
- locked: newLocked,
1045
- }));
1046
- }
1047
- }}
1048
- className={cn(
1049
- 'mb-1 flex items-center gap-1.5 rounded-md border px-3 py-1.5 font-medium text-xs transition-colors',
1050
- event.locked
1051
- ? 'border-dynamic-amber/30 bg-dynamic-amber/10 text-dynamic-amber'
1052
- : 'border-border bg-background text-muted-foreground hover:bg-muted'
1053
- )}
1054
- title={
1055
- event.locked
1056
- ? 'Locked: Auto-scheduling will not move this event'
1057
- : 'Unlocked: Auto-scheduling may move this event'
1058
- }
1059
- >
1060
- {event.locked ? (
1061
- <Lock className="h-3.5 w-3.5" />
1062
- ) : (
1063
- <Unlock className="h-3.5 w-3.5" />
1064
- )}
1065
- {event.locked ? 'Locked' : 'Unlocked'}
1066
- </button>
1067
- </div>
1068
- </div>
1069
-
1070
- {/* Warnings and Errors */}
1071
- {(showOverlapWarning || dateError) && (
1072
- <>
1073
- <Separator />
1074
- <div className="space-y-3">
1075
- {showOverlapWarning && (
1076
- <OverlapWarning
1077
- overlappingEvents={overlappingEvents}
1078
- />
1079
- )}
1080
- {dateError && <DateError error={dateError} />}
1081
- </div>
1082
- </>
1083
- )}
1084
- </div>
1085
- </ScrollArea>
1086
-
1087
- {/* Action Buttons */}
1088
- <div className="mt-auto border-t p-2">
1089
- {draftSave.recoveryNotice}
1090
- <div className="flex justify-between">
1091
- {isEditing ? (
1092
- <Button
1093
- variant="destructive"
1094
- onClick={handleDelete}
1095
- disabled={isSaving || isDeleting}
1096
- className="flex items-center gap-2"
1097
- >
1098
- {isDeleting ? (
1099
- <>
1100
- <Loader2 className="h-4 w-4 animate-spin" />
1101
- <span>Deleting...</span>
1102
- </>
1103
- ) : (
1104
- <>
1105
- <Trash2 className="h-4 w-4" />
1106
- <span>Delete</span>
1107
- </>
1108
- )}
1109
- </Button>
1110
- ) : (
1111
- <div />
1112
- )}
1113
-
1114
- <div className="flex gap-2">
1115
- <Button
1116
- onClick={handleManualSave}
1117
- disabled={isSaving || isDeleting}
1118
- className="flex items-center gap-2"
1119
- >
1120
- {isSaving ? (
1121
- <>
1122
- <Loader2 className="h-4 w-4 animate-spin" />
1123
- <span>Saving...</span>
1124
- </>
1125
- ) : (
1126
- <>
1127
- <Check className="h-4 w-4" />
1128
- <span>
1129
- {isEditing ? 'Save Changes' : 'Create Event'}
1130
- </span>
1131
- </>
1132
- )}
1133
- </Button>
1134
- </div>
1135
- </div>
1136
- </div>
1137
- </div>
1138
- </TabsContent>
1139
-
1140
- {/* AI Event Generation Tab */}
1141
- <TabsContent
1142
- value="ai"
1143
- className="h-full p-0 focus-visible:outline-none focus-visible:ring-0 data-[state=active]:flex data-[state=active]:flex-col"
1144
- style={{ display: activeTab === 'ai' ? 'flex' : 'none' }}
1145
- >
1146
- <Form {...aiForm}>
1147
- <form
1148
- onSubmit={aiForm.handleSubmit(handleGenerateEvent)}
1149
- className="flex h-full flex-1 flex-col"
1150
- >
1151
- <div className="flex flex-1 flex-col overflow-hidden">
1152
- <ScrollArea className="h-[calc(90vh-250px)] flex-1">
1153
- <div className="space-y-6 p-6">
1154
- <FormField
1155
- control={aiForm.control}
1156
- name="prompt"
1157
- render={({ field }) => (
1158
- <FormItem>
1159
- <FormLabel className="font-medium text-base">
1160
- Describe your event
1161
- </FormLabel>
1162
- <FormControl>
1163
- <div className="relative w-full">
1164
- <AutosizeTextarea
1165
- {...field}
1166
- autoFocus
1167
- placeholder="E.g., Schedule a team meeting next Monday at 2pm for 1 hour..."
1168
- className="min-h-50 w-full resize-none rounded-md border border-input bg-background p-4 pr-20 text-base focus:outline-none focus:ring-1 focus:ring-ring"
1169
- disabled={
1170
- isLoading ||
1171
- isRecording ||
1172
- isProcessingAudio ||
1173
- isProcessingImage
1174
- }
1175
- />
1176
-
1177
- {/* Record Button */}
1178
- <div className="absolute right-2 bottom-2 flex items-center gap-1">
1179
- <Button
1180
- size="xs"
1181
- type="button"
1182
- variant={
1183
- isRecording ? 'destructive' : 'default'
1184
- }
1185
- onClick={
1186
- isRecording
1187
- ? stopRecording
1188
- : startRecording
1189
- }
1190
- disabled={
1191
- isProcessingAudio ||
1192
- isProcessingImage ||
1193
- isLoading
1194
- }
1195
- className="flex items-center rounded-md"
1196
- >
1197
- {isRecording ? (
1198
- <StopCircle className="h-4 w-4" />
1199
- ) : isProcessingAudio ? (
1200
- <Loader2 className="h-4 w-4 animate-spin" />
1201
- ) : (
1202
- <Mic className="h-4 w-4" />
1203
- )}
1204
- </Button>
1205
-
1206
- {/* 📸 Image button */}
1207
- <Button
1208
- size="xs"
1209
- type="button"
1210
- variant="default"
1211
- onClick={triggerImageUpload}
1212
- disabled={
1213
- isRecording ||
1214
- isProcessingAudio ||
1215
- isProcessingImage ||
1216
- isLoading
1217
- }
1218
- className="flex items-center rounded-md"
1219
- >
1220
- {isProcessingImage ? (
1221
- <Loader2 className="h-4 w-4 animate-spin" />
1222
- ) : (
1223
- <ImageIcon className="h-4 w-4" />
1224
- )}
1225
- </Button>
1226
- <input
1227
- type="file"
1228
- ref={fileInputRef}
1229
- accept="image/*"
1230
- onChange={handleUploadImage}
1231
- className="hidden"
1232
- />
1233
- </div>
1234
- </div>
1235
- </FormControl>
1236
- <FormDescription className="flex items-center gap-1 text-xs">
1237
- <Info className="h-3 w-3" />
1238
- Be as specific as possible for best results
1239
- </FormDescription>
1240
- <FormMessage />
1241
- </FormItem>
1242
- )}
1243
- />
1244
- {isLoading && (
1245
- <div className="flex items-center justify-center py-8">
1246
- <div className="flex flex-col items-center gap-2">
1247
- <Loader2 className="h-8 w-8 animate-spin text-primary" />
1248
- <p className="text-muted-foreground text-sm">
1249
- Creating your event...
1250
- </p>
1251
- </div>
1252
- </div>
1253
- )}
1254
- {error && (
1255
- <Alert variant="destructive">
1256
- <AlertCircle className="h-4 w-4" />
1257
- <AlertTitle>Error</AlertTitle>
1258
- <AlertDescription>
1259
- {error.message ||
1260
- 'Failed to generate event. Please try again.'}
1261
- </AlertDescription>
1262
- </Alert>
1263
- )}
1264
- </div>
1265
- </ScrollArea>
1266
-
1267
- {/* Action Buttons */}
1268
- <div className="mt-auto border-t p-6">
1269
- <div className="flex justify-between">
1270
- <Button
1271
- variant="outline"
1272
- onClick={closeModal}
1273
- disabled={isLoading}
1274
- className="flex items-center gap-2"
1275
- >
1276
- <X className="h-4 w-4" />
1277
- <span>Cancel</span>
1278
- </Button>
1279
- <Button
1280
- type="submit"
1281
- disabled={isLoading || !aiForm.watch('prompt')}
1282
- className="flex items-center gap-2 bg-primary"
1283
- >
1284
- {isLoading ? (
1285
- <>
1286
- <Loader2 className="h-4 w-4 animate-spin" />
1287
- <span>Creating...</span>
1288
- </>
1289
- ) : (
1290
- <>
1291
- <Sparkles className="h-4 w-4" />
1292
- <span>Generate Event</span>
1293
- </>
1294
- )}
1295
- </Button>
1296
- </div>
1297
- </div>
1298
- </div>
1299
- </form>
1300
- </Form>
1301
- </TabsContent>
1302
-
1303
- {/* Preview Tab */}
1304
- <TabsContent
1305
- value="preview"
1306
- className="h-full p-0 focus-visible:outline-none focus-visible:ring-0 data-[state=active]:flex data-[state=active]:flex-col"
1307
- style={{ display: activeTab === 'preview' ? 'flex' : 'none' }}
1308
- >
1309
- <div className="flex flex-1 flex-col overflow-hidden">
1310
- <ScrollArea className="h-[calc(90vh-250px)] flex-1">
1311
- <div className="space-y-6 p-6">
1312
- {/* AI Generated Event Preview */}
1313
- <div className="rounded-lg border bg-muted/10 p-4">
1314
- <div className="mb-3 flex items-center justify-between">
1315
- <h3 className="flex items-center gap-2 font-medium text-base">
1316
- <Sparkles className="h-4 w-4 text-primary" />
1317
- AI Generated Event
1318
- {(generatedEvents?.length || 0) > 1 ? 's' : ''}
1319
- </h3>
1320
- <div className="flex items-center gap-2">
1321
- {(generatedEvents?.length || 0) > 1 && (
1322
- <div className="flex items-center gap-1 text-muted-foreground text-xs">
1323
- <span>{currentEventIndex + 1}</span>
1324
- <span>/</span>
1325
- <span>{generatedEvents?.length || 0}</span>
1326
- </div>
1327
- )}
1328
- <Badge variant="outline" className="text-xs">
1329
- Preview
1330
- </Badge>
1331
- </div>
1332
- </div>
1333
-
1334
- {generatedEvent && (
1335
- <div className="space-y-4">
1336
- <div className="mt-3 space-y-3 rounded-md border p-3">
1337
- <div className="space-y-2">
1338
- <h4 className="font-medium text-lg">
1339
- {generatedEvent.title}
1340
- </h4>
1341
- <div className="flex flex-wrap gap-2 text-muted-foreground text-sm">
1342
- <div className="flex items-center gap-1">
1343
- <CalendarIcon className="h-3.5 w-3.5" />
1344
- <span>
1345
- {format(
1346
- new Date(generatedEvent.start_at || ''),
1347
- 'PPP'
1348
- )}
1349
- </span>
1350
- </div>
1351
- <div className="flex items-center gap-1">
1352
- <Clock className="h-3.5 w-3.5" />
1353
- <span>
1354
- {format(
1355
- new Date(generatedEvent.start_at || ''),
1356
- settings?.appearance?.timeFormat === '24h'
1357
- ? 'HH:mm'
1358
- : 'h:mm a'
1359
- )}{' '}
1360
- -{' '}
1361
- {format(
1362
- new Date(generatedEvent.end_at || ''),
1363
- settings?.appearance?.timeFormat === '24h'
1364
- ? 'HH:mm'
1365
- : 'h:mm a'
1366
- )}
1367
- </span>
1368
- </div>
1369
- {generatedEvent.location && (
1370
- <div className="flex items-center gap-1">
1371
- <MapPin className="h-3.5 w-3.5" />
1372
- <button
1373
- type="button"
1374
- onClick={() =>
1375
- openGoogleMaps(
1376
- generatedEvent.location || ''
1377
- )
1378
- }
1379
- className="text-muted-foreground text-sm hover:text-primary hover:underline"
1380
- title="Open in Google Maps"
1381
- >
1382
- {generatedEvent.location}
1383
- </button>
1384
- </div>
1385
- )}
1386
- </div>
1387
- </div>
1388
-
1389
- {generatedEvent.description && (
1390
- <div className="space-y-1">
1391
- <h5 className="font-medium text-sm">
1392
- Description
1393
- </h5>
1394
- <p className="text-muted-foreground text-sm">
1395
- {generatedEvent.description}
1396
- </p>
1397
- </div>
1398
- )}
1399
-
1400
- <Separator />
1401
-
1402
- {/* Color indicator and category */}
1403
- <div className="flex items-center gap-2">
1404
- {generatedEvent.color &&
1405
- (() => {
1406
- const { bg, border, text } = getEventStyles(
1407
- generatedEvent.color
1408
- );
1409
- return (
1410
- <div
1411
- className={`flex items-center gap-2 rounded-full border px-2 py-1 text-center ${bg} ${border}`}
1412
- >
1413
- <span
1414
- className={`font-medium text-xs ${text}`}
1415
- >
1416
- {COLOR_OPTIONS.find(
1417
- (c) =>
1418
- c.value ===
1419
- (generatedEvent.color
1420
- ? generatedEvent.color.toUpperCase()
1421
- : 'BLUE')
1422
- )?.name || 'Default'}
1423
- </span>
1424
- </div>
1425
- );
1426
- })()}
1427
- </div>
1428
-
1429
- {/* Event protection status */}
1430
- <div className="flex items-center gap-2">
1431
- <Unlock className="h-3.5 w-3.5 text-muted-foreground" />
1432
- <span className="text-muted-foreground text-xs">
1433
- Event will be created unlocked
1434
- </span>
1435
- </div>
1436
- </div>
1437
- </div>
1438
- )}
1439
-
1440
- {/* Navigation buttons for multiple events */}
1441
- {(generatedEvents?.length || 0) > 1 && (
1442
- <div className="mt-4 flex items-center justify-center gap-2">
1443
- <Button
1444
- variant="outline"
1445
- size="sm"
1446
- onClick={goToPreviousEvent}
1447
- disabled={currentEventIndex === 0}
1448
- className="h-8 w-8 p-0"
1449
- >
1450
- <ChevronLeft className="h-4 w-4" />
1451
- </Button>
1452
- <Button
1453
- variant="outline"
1454
- size="sm"
1455
- onClick={goToNextEvent}
1456
- disabled={
1457
- currentEventIndex ===
1458
- (generatedEvents?.length || 0) - 1
1459
- }
1460
- className="h-8 w-8 p-0"
1461
- >
1462
- <ChevronRight className="h-4 w-4" />
1463
- </Button>
1464
- </div>
1465
- )}
1466
- </div>
1467
-
1468
- {/* AI Insights and Suggestions */}
1469
- {aiSuggestions.length > 0 && (
1470
- <div className="rounded-lg border bg-muted/10 p-4">
1471
- <h3 className="mb-3 flex items-center gap-2 font-medium text-sm">
1472
- <Brain className="h-4 w-4 text-primary" />
1473
- AI Insights & Suggestions
1474
- </h3>
1475
- <ul className="space-y-2">
1476
- {aiSuggestions.map((suggestion) => (
1477
- <li
1478
- key={suggestion}
1479
- className="flex items-start gap-2 text-sm"
1480
- >
1481
- <div className="mt-0.5 text-primary">•</div>
1482
- <span>{suggestion}</span>
1483
- </li>
1484
- ))}
1485
- </ul>
1486
- </div>
1487
- )}
1488
-
1489
- {/* Warnings and Errors */}
1490
- {showOverlapWarning && (
1491
- <OverlapWarning overlappingEvents={overlappingEvents} />
1492
- )}
1493
- </div>
1494
- </ScrollArea>
1495
-
1496
- {/* Action Buttons */}
1497
- <div className="mt-auto border-t p-6">
1498
- <div className="flex justify-between">
1499
- <div className="flex gap-2">
1500
- <Button
1501
- variant="outline"
1502
- onClick={() => setActiveTab('ai')}
1503
- className="flex items-center gap-2"
1504
- >
1505
- <ChevronLeft className="h-4 w-4" />
1506
- <span>Back</span>
1507
- </Button>
1508
- <Button
1509
- variant="outline"
1510
- onClick={() => {
1511
- // Transfer AI generated event to manual form
1512
- if (generatedEvent) {
1513
- setEvent({
1514
- ...generatedEvent,
1515
- id: undefined, // Remove ID to create a new event
1516
- });
1517
- setActiveTab('manual');
1518
- }
1519
- }}
1520
- className="flex items-center gap-2"
1521
- >
1522
- <FileText className="h-4 w-4" />
1523
- <span>Edit Details</span>
1524
- </Button>
1525
- </div>
1526
- <Button
1527
- onClick={handleAISave}
1528
- disabled={
1529
- isSaving || (generatedEvents?.length || 0) === 0
1530
- }
1531
- className="flex items-center gap-2"
1532
- >
1533
- {isSaving ? (
1534
- <>
1535
- <Loader2 className="h-4 w-4 animate-spin" />
1536
- <span>Saving...</span>
1537
- </>
1538
- ) : (
1539
- <>
1540
- <Check className="h-4 w-4" />
1541
- <span>
1542
- Save{' '}
1543
- {(generatedEvents?.length || 0) > 1
1544
- ? 'All Events'
1545
- : 'Event'}
1546
- </span>
1547
- </>
1548
- )}
1549
- </Button>
1550
- </div>
1551
- </div>
1552
- </div>
1553
- </TabsContent>
1554
- </div>
1555
- </Tabs>
1556
- </DialogContent>
1557
- </Dialog>
21
+ <NativeRecurrenceDialog
22
+ wsId={activeEvent.ws_id ?? ''}
23
+ open={isModalOpen}
24
+ onOpenChange={(open) => {
25
+ if (!open) closeModal();
26
+ }}
27
+ occurrence={activeEvent}
28
+ readOnly={readOnly}
29
+ />
1558
30
  );
1559
31
  }