@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.
- package/CHANGELOG.md +27 -0
- package/package.json +6 -6
- package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
- package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
- package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
- package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
- package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
- package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
- package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
- package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
- package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
- package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
- package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
- package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
- package/src/components/ui/finance/invoices/hooks.ts +3 -100
- package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
- package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
- package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
- package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
- package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
- package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
- package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
- package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
- package/src/components/ui/finance/invoices/types.ts +3 -3
- package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
- package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
- package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
- package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
- package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
- package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
- package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
- package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
- package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
- package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
- package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
- package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
- package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
- package/src/components/ui/text-editor/editor.tsx +3 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
- package/src/components/ui/text-editor/tool-bar.tsx +18 -74
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
- package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
- package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
- package/src/hooks/calendar-connection-types.ts +49 -0
- package/src/hooks/calendar-event-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +33 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/supabase-provider.ts +39 -41
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +24 -38
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-yjs-collaboration.ts +23 -0
|
@@ -0,0 +1,1559 @@
|
|
|
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 { Alert, AlertDescription, AlertTitle } from '../../alert';
|
|
77
|
+
import { AutosizeTextarea } from '../../custom/autosize-textarea';
|
|
78
|
+
import {
|
|
79
|
+
COLOR_OPTIONS,
|
|
80
|
+
DateError,
|
|
81
|
+
EventDateTimePicker,
|
|
82
|
+
EventDescriptionInput,
|
|
83
|
+
EventLocationInput,
|
|
84
|
+
EventTitleInput,
|
|
85
|
+
EventToggleSwitch,
|
|
86
|
+
OverlapWarning,
|
|
87
|
+
} from './event-form-components';
|
|
88
|
+
import { EventModalHeader } from './event-modal-header';
|
|
89
|
+
import { eventEndPickerBounds } from './event-picker-bounds';
|
|
90
|
+
import { eventModalDraft, eventModalSavePayload } from './event-save-payload';
|
|
91
|
+
import {
|
|
92
|
+
eventSourceChanged,
|
|
93
|
+
selectedEventSource,
|
|
94
|
+
sourceInputFromOption,
|
|
95
|
+
} from './event-source-selection';
|
|
96
|
+
import { ProviderColorPicker } from './provider-color-picker';
|
|
97
|
+
import {
|
|
98
|
+
prepareCalendarEventDrafts,
|
|
99
|
+
saveCalendarEventDrafts,
|
|
100
|
+
} from './save-calendar-event-drafts';
|
|
101
|
+
import { useCalendarSettings } from './settings/settings-context';
|
|
102
|
+
import { useCalendarDraftSave } from './use-calendar-draft-save';
|
|
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
|
+
|
|
116
|
+
export function LegacyEventModal() {
|
|
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
|
+
|
|
857
|
+
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>
|
|
1558
|
+
);
|
|
1559
|
+
}
|