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