- EventForm + EventDetailPanel: native <Input type=date|datetime-local> → DatePicker with dynamic mode via all_day toggle - TodoForm + TodoDetailPanel: merge date + time into single datetime DatePicker, remove separate time input, move recurrence select into 2-col grid beside date picker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
469 lines
17 KiB
TypeScript
469 lines
17 KiB
TypeScript
import { useState, FormEvent } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import api, { getErrorMessage } from '@/lib/api';
|
|
import type { CalendarEvent, Location, RecurrenceRule } from '@/types';
|
|
import { useCalendars } from '@/hooks/useCalendars';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetFooter,
|
|
SheetClose,
|
|
} from '@/components/ui/sheet';
|
|
import { Input } from '@/components/ui/input';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Select } from '@/components/ui/select';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import LocationPicker from '@/components/ui/location-picker';
|
|
|
|
interface EventFormProps {
|
|
event: CalendarEvent | null;
|
|
templateData?: Partial<CalendarEvent> | null;
|
|
templateName?: string | null;
|
|
initialStart?: string | null;
|
|
initialEnd?: string | null;
|
|
initialAllDay?: boolean;
|
|
editScope?: 'this' | 'this_and_future' | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function toDateOnly(dt: string): string {
|
|
if (!dt) return '';
|
|
return dt.split('T')[0];
|
|
}
|
|
|
|
function toDatetimeLocal(dt: string, fallbackTime: string = '09:00'): string {
|
|
if (!dt) return '';
|
|
if (dt.includes('T')) return dt.slice(0, 16);
|
|
return `${dt}T${fallbackTime}`;
|
|
}
|
|
|
|
function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:00'): string {
|
|
if (!dt) return '';
|
|
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
|
|
}
|
|
|
|
/** FullCalendar uses exclusive end dates for all-day events. Subtract 1 day for display. */
|
|
function adjustAllDayEndForDisplay(dateStr: string): string {
|
|
if (!dateStr) return '';
|
|
const d = new Date(dateStr.split('T')[0] + 'T12:00:00');
|
|
d.setDate(d.getDate() - 1);
|
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
}
|
|
|
|
/** Add 1 day to form end date before sending to API for all-day events. */
|
|
function adjustAllDayEndForSave(dateStr: string): string {
|
|
if (!dateStr) return '';
|
|
const d = new Date(dateStr + 'T12:00:00');
|
|
d.setDate(d.getDate() + 1);
|
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
}
|
|
|
|
// Python weekday: 0=Monday, 6=Sunday
|
|
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
|
|
function parseRecurrenceRule(raw?: string): RecurrenceRule | null {
|
|
if (!raw) return null;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function nowLocal(): string {
|
|
const now = new Date();
|
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
}
|
|
|
|
function plusOneHour(dt: string): string {
|
|
const d = new Date(dt);
|
|
d.setHours(d.getHours() + 1);
|
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
export default function EventForm({ event, templateData, templateName, initialStart, initialEnd, initialAllDay, editScope, onClose }: EventFormProps) {
|
|
const queryClient = useQueryClient();
|
|
const { data: calendars = [] } = useCalendars();
|
|
// Merge template data as defaults for new events
|
|
const source = event || templateData;
|
|
const isAllDay = source?.all_day ?? initialAllDay ?? false;
|
|
|
|
// Default to current time / +1 hour when creating a new event with no selection
|
|
const defaultStart = nowLocal();
|
|
const defaultEnd = plusOneHour(defaultStart);
|
|
const rawStart = event?.start_datetime || initialStart || defaultStart;
|
|
const rawEnd = event?.end_datetime || initialEnd || defaultEnd;
|
|
|
|
const defaultCalendar = calendars.find((c) => c.is_default);
|
|
const initialCalendarId = source?.calendar_id?.toString() || defaultCalendar?.id?.toString() || '';
|
|
const isEditing = !!event?.id;
|
|
|
|
// For all-day events, adjust end date for display (FullCalendar exclusive end)
|
|
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd;
|
|
|
|
const [formData, setFormData] = useState({
|
|
title: source?.title || '',
|
|
description: source?.description || '',
|
|
start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
|
|
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
|
|
all_day: isAllDay,
|
|
location_id: source?.location_id?.toString() || '',
|
|
calendar_id: initialCalendarId,
|
|
is_starred: source?.is_starred || false,
|
|
});
|
|
|
|
const existingRule = parseRecurrenceRule(source?.recurrence_rule);
|
|
const [recurrenceType, setRecurrenceType] = useState<string>(existingRule?.type || '');
|
|
const [recurrenceInterval, setRecurrenceInterval] = useState(existingRule?.interval || 2);
|
|
const [recurrenceWeekday, setRecurrenceWeekday] = useState(existingRule?.weekday ?? 1);
|
|
const [recurrenceWeek, setRecurrenceWeek] = useState(existingRule?.week || 1);
|
|
const [recurrenceDay, setRecurrenceDay] = useState(existingRule?.day || 1);
|
|
|
|
const { data: locations = [] } = useQuery({
|
|
queryKey: ['locations'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<Location[]>('/locations');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
// Location picker state
|
|
const existingLocation = locations.find((l) => l.id === source?.location_id);
|
|
const [locationSearch, setLocationSearch] = useState(existingLocation?.name || '');
|
|
|
|
const selectableCalendars = calendars.filter((c) => !c.is_system);
|
|
|
|
const buildRecurrenceRule = (): RecurrenceRule | null => {
|
|
if (!recurrenceType) return null;
|
|
switch (recurrenceType) {
|
|
case 'every_n_days':
|
|
return { type: 'every_n_days', interval: recurrenceInterval };
|
|
case 'weekly':
|
|
// No weekday needed — backend derives it from the event's start date
|
|
return { type: 'weekly' };
|
|
case 'monthly_nth_weekday':
|
|
return { type: 'monthly_nth_weekday', week: recurrenceWeek, weekday: recurrenceWeekday };
|
|
case 'monthly_date':
|
|
return { type: 'monthly_date', day: recurrenceDay };
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async (data: typeof formData) => {
|
|
const rule = buildRecurrenceRule();
|
|
|
|
// Adjust end date for all-day events before save
|
|
let endDt = data.end_datetime;
|
|
if (data.all_day && endDt) {
|
|
endDt = adjustAllDayEndForSave(endDt);
|
|
}
|
|
|
|
const payload: Record<string, any> = {
|
|
title: data.title,
|
|
description: data.description,
|
|
start_datetime: data.start_datetime,
|
|
end_datetime: endDt,
|
|
all_day: data.all_day,
|
|
location_id: data.location_id ? parseInt(data.location_id) : null,
|
|
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
|
|
is_starred: data.is_starred,
|
|
recurrence_rule: rule,
|
|
};
|
|
|
|
if (isEditing) {
|
|
if (editScope) {
|
|
payload.edit_scope = editScope;
|
|
}
|
|
const response = await api.put(`/events/${event!.id}`, payload);
|
|
return response.data;
|
|
} else {
|
|
const response = await api.post('/events', payload);
|
|
return response.data;
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
|
toast.success(isEditing ? 'Event updated' : 'Event created');
|
|
onClose();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, isEditing ? 'Failed to update event' : 'Failed to create event'));
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const params = editScope ? `?scope=${editScope}` : '';
|
|
await api.delete(`/events/${event?.id}${params}`);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
|
toast.success('Event deleted');
|
|
onClose();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, 'Failed to delete event'));
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
e.preventDefault();
|
|
mutation.mutate(formData);
|
|
};
|
|
|
|
return (
|
|
<Sheet open={true} onOpenChange={onClose}>
|
|
<SheetContent>
|
|
<SheetClose onClick={onClose} />
|
|
<SheetHeader>
|
|
<SheetTitle>
|
|
{isEditing
|
|
? 'Edit Event'
|
|
: templateName
|
|
? `Create Event from ${templateName} Template`
|
|
: 'New Event'}
|
|
</SheetTitle>
|
|
</SheetHeader>
|
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
|
|
<div className="px-6 py-5 space-y-4 flex-1">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="title" required>Title</Label>
|
|
<Input
|
|
id="title"
|
|
value={formData.title}
|
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
className="min-h-[80px] flex-1"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="all_day"
|
|
checked={formData.all_day}
|
|
onChange={(e) => {
|
|
const checked = (e.target as HTMLInputElement).checked;
|
|
setFormData({
|
|
...formData,
|
|
all_day: checked,
|
|
start_datetime: formatForInput(formData.start_datetime, checked, '09:00'),
|
|
end_datetime: formatForInput(formData.end_datetime, checked, '10:00'),
|
|
});
|
|
}}
|
|
/>
|
|
<Label htmlFor="all_day">All day event</Label>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="start" required>Start</Label>
|
|
<DatePicker
|
|
variant="input"
|
|
id="start"
|
|
mode={formData.all_day ? 'date' : 'datetime'}
|
|
value={formData.start_datetime}
|
|
onChange={(v) => setFormData({ ...formData, start_datetime: v })}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="end">End</Label>
|
|
<DatePicker
|
|
variant="input"
|
|
id="end"
|
|
mode={formData.all_day ? 'date' : 'datetime'}
|
|
value={formData.end_datetime}
|
|
onChange={(v) => setFormData({ ...formData, end_datetime: v })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="calendar">Calendar</Label>
|
|
<Select
|
|
id="calendar"
|
|
value={formData.calendar_id}
|
|
onChange={(e) => setFormData({ ...formData, calendar_id: e.target.value })}
|
|
>
|
|
{selectableCalendars.map((cal) => (
|
|
<option key={cal.id} value={cal.id}>
|
|
{cal.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="location">Location</Label>
|
|
<LocationPicker
|
|
id="location"
|
|
value={locationSearch}
|
|
onChange={(val) => {
|
|
setLocationSearch(val);
|
|
if (!val) setFormData({ ...formData, location_id: '' });
|
|
}}
|
|
onSelect={async (result) => {
|
|
if (result.source === 'local' && result.location_id) {
|
|
setFormData({ ...formData, location_id: result.location_id.toString() });
|
|
} else if (result.source === 'nominatim') {
|
|
try {
|
|
const { data: newLoc } = await api.post('/locations', {
|
|
name: result.name,
|
|
address: result.address,
|
|
category: 'other',
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ['locations'] });
|
|
setFormData({ ...formData, location_id: newLoc.id.toString() });
|
|
toast.success(`Location "${result.name}" created`);
|
|
} catch {
|
|
toast.error('Failed to create location');
|
|
}
|
|
}
|
|
}}
|
|
placeholder="Search for a location..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recurrence picker */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="recurrence_type">Recurrence</Label>
|
|
<Select
|
|
id="recurrence_type"
|
|
value={recurrenceType}
|
|
onChange={(e) => setRecurrenceType(e.target.value)}
|
|
>
|
|
<option value="">None</option>
|
|
<option value="every_n_days">Every X days</option>
|
|
<option value="weekly">Weekly</option>
|
|
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
|
|
<option value="monthly_date">Monthly (date)</option>
|
|
</Select>
|
|
</div>
|
|
|
|
{recurrenceType === 'every_n_days' && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="interval">Every how many days?</Label>
|
|
<Input
|
|
id="interval"
|
|
type="number"
|
|
min={1}
|
|
max={365}
|
|
value={recurrenceInterval}
|
|
onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{recurrenceType === 'weekly' && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Repeats every week on the same day as the start date.
|
|
</p>
|
|
)}
|
|
|
|
{recurrenceType === 'monthly_nth_weekday' && (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="week">Week of month</Label>
|
|
<Select
|
|
id="week"
|
|
value={recurrenceWeek.toString()}
|
|
onChange={(e) => setRecurrenceWeek(parseInt(e.target.value))}
|
|
>
|
|
<option value="1">1st</option>
|
|
<option value="2">2nd</option>
|
|
<option value="3">3rd</option>
|
|
<option value="4">4th</option>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="weekday_nth">Day of week</Label>
|
|
<Select
|
|
id="weekday_nth"
|
|
value={recurrenceWeekday.toString()}
|
|
onChange={(e) => setRecurrenceWeekday(parseInt(e.target.value))}
|
|
>
|
|
{WEEKDAYS.map((name, i) => (
|
|
<option key={i} value={i}>{name}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{recurrenceType === 'monthly_date' && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="day">Day of month</Label>
|
|
<Input
|
|
id="day"
|
|
type="number"
|
|
min={1}
|
|
max={31}
|
|
value={recurrenceDay}
|
|
onChange={(e) => setRecurrenceDay(parseInt(e.target.value) || 1)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="is_starred"
|
|
checked={formData.is_starred}
|
|
onChange={(e) => setFormData({ ...formData, is_starred: (e.target as HTMLInputElement).checked })}
|
|
/>
|
|
<Label htmlFor="is_starred">Star this event</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<SheetFooter>
|
|
{isEditing && (
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() => deleteMutation.mutate()}
|
|
disabled={deleteMutation.isPending}
|
|
className="mr-auto"
|
|
>
|
|
Delete
|
|
</Button>
|
|
)}
|
|
<Button type="button" variant="outline" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={mutation.isPending}>
|
|
{mutation.isPending ? 'Saving...' : isEditing ? 'Update' : 'Create'}
|
|
</Button>
|
|
</SheetFooter>
|
|
</form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|