1096 lines
40 KiB
TypeScript
1096 lines
40 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { format, parseISO } from 'date-fns';
|
|
import {
|
|
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, LogOut,
|
|
} from 'lucide-react';
|
|
import axios from 'axios';
|
|
import api, { getErrorMessage } from '@/lib/api';
|
|
import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarPermission, EventLockInfo } from '@/types';
|
|
import { useCalendars } from '@/hooks/useCalendars';
|
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
|
import { useEventLock } from '@/hooks/useEventLock';
|
|
import { useEventInvitations, useConnectedUsersSearch } from '@/hooks/useEventInvitations';
|
|
import { formatUpdatedAt } from '@/components/shared/utils';
|
|
import CopyableField from '@/components/shared/CopyableField';
|
|
import EventLockBanner from './EventLockBanner';
|
|
import { InviteeList, InviteSearch, RsvpButtons } from './InviteeSection';
|
|
import LeaveEventDialog from './LeaveEventDialog';
|
|
import { Button } from '@/components/ui/button';
|
|
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 { Checkbox } from '@/components/ui/checkbox';
|
|
import { Label } from '@/components/ui/label';
|
|
import LocationPicker from '@/components/ui/location-picker';
|
|
|
|
// --- Helpers ---
|
|
|
|
function toDateOnly(dt: string): string {
|
|
if (!dt) return '';
|
|
return dt.split('T')[0];
|
|
}
|
|
|
|
function toDatetimeLocal(dt: string, fallbackTime = '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 = '09:00'): string {
|
|
if (!dt) return '';
|
|
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
|
|
}
|
|
|
|
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())}`;
|
|
}
|
|
|
|
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())}`;
|
|
}
|
|
|
|
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())}`;
|
|
}
|
|
|
|
function formatRecurrenceRule(rule: string): string {
|
|
try {
|
|
const parsed = JSON.parse(rule);
|
|
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
switch (parsed.type) {
|
|
case 'every_n_days':
|
|
return parsed.interval === 1 ? 'Every day' : `Every ${parsed.interval} days`;
|
|
case 'weekly':
|
|
return parsed.weekday != null ? `Weekly on ${weekdays[parsed.weekday]}` : 'Weekly';
|
|
case 'monthly_nth_weekday':
|
|
return parsed.week && parsed.weekday != null
|
|
? `Monthly on week ${parsed.week}, ${weekdays[parsed.weekday]}`
|
|
: 'Monthly';
|
|
case 'monthly_date':
|
|
return parsed.day ? `Monthly on the ${parsed.day}${ordinal(parsed.day)}` : 'Monthly';
|
|
default:
|
|
return 'Recurring';
|
|
}
|
|
} catch {
|
|
return 'Recurring';
|
|
}
|
|
}
|
|
|
|
function ordinal(n: number): string {
|
|
const s = ['th', 'st', 'nd', 'rd'];
|
|
const v = n % 100;
|
|
return s[(v - 20) % 10] || s[v] || s[0];
|
|
}
|
|
|
|
function parseRecurrenceRule(raw?: string): RecurrenceRule | null {
|
|
if (!raw) return null;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Python weekday: 0=Monday, 6=Sunday
|
|
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
|
|
// --- Types ---
|
|
|
|
export interface CreateDefaults {
|
|
start?: string;
|
|
end?: string;
|
|
allDay?: boolean;
|
|
templateData?: Partial<CalendarEvent>;
|
|
templateName?: string;
|
|
}
|
|
|
|
interface EventDetailPanelProps {
|
|
event: CalendarEvent | null;
|
|
isCreating?: boolean;
|
|
createDefaults?: CreateDefaults | null;
|
|
onClose: () => void;
|
|
onSaved?: () => void;
|
|
onDeleted?: () => void;
|
|
locationName?: string;
|
|
myPermission?: CalendarPermission | 'owner' | null;
|
|
isSharedEvent?: boolean;
|
|
}
|
|
|
|
interface EditState {
|
|
title: string;
|
|
description: string;
|
|
start_datetime: string;
|
|
end_datetime: string;
|
|
all_day: boolean;
|
|
location_id: string;
|
|
calendar_id: string;
|
|
is_starred: boolean;
|
|
recurrence_type: string;
|
|
recurrence_interval: number;
|
|
recurrence_weekday: number;
|
|
recurrence_week: number;
|
|
recurrence_day: number;
|
|
}
|
|
|
|
function buildEditStateFromEvent(event: CalendarEvent): EditState {
|
|
const rule = parseRecurrenceRule(event.recurrence_rule);
|
|
const isAllDay = event.all_day;
|
|
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(event.end_datetime) : event.end_datetime;
|
|
return {
|
|
title: event.title,
|
|
description: event.description || '',
|
|
start_datetime: formatForInput(event.start_datetime, isAllDay, '09:00'),
|
|
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
|
|
all_day: isAllDay,
|
|
location_id: event.location_id?.toString() || '',
|
|
calendar_id: event.calendar_id?.toString() || '',
|
|
is_starred: event.is_starred || false,
|
|
recurrence_type: rule?.type || '',
|
|
recurrence_interval: rule?.interval || 2,
|
|
recurrence_weekday: rule?.weekday ?? 1,
|
|
recurrence_week: rule?.week || 1,
|
|
recurrence_day: rule?.day || 1,
|
|
};
|
|
}
|
|
|
|
function buildCreateState(defaults: CreateDefaults | null, defaultCalendarId: string): EditState {
|
|
const source = defaults?.templateData;
|
|
const isAllDay = source?.all_day ?? defaults?.allDay ?? false;
|
|
const defaultStart = nowLocal();
|
|
const defaultEnd = plusOneHour(defaultStart);
|
|
const rawStart = defaults?.start || defaultStart;
|
|
const rawEnd = defaults?.end || defaultEnd;
|
|
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd;
|
|
const rule = parseRecurrenceRule(source?.recurrence_rule);
|
|
|
|
return {
|
|
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: source?.calendar_id?.toString() || defaultCalendarId,
|
|
is_starred: source?.is_starred || false,
|
|
recurrence_type: rule?.type || '',
|
|
recurrence_interval: rule?.interval || 2,
|
|
recurrence_weekday: rule?.weekday ?? 1,
|
|
recurrence_week: rule?.week || 1,
|
|
recurrence_day: rule?.day || 1,
|
|
};
|
|
}
|
|
|
|
function buildRecurrencePayload(state: EditState): RecurrenceRule | null {
|
|
if (!state.recurrence_type) return null;
|
|
switch (state.recurrence_type) {
|
|
case 'every_n_days':
|
|
return { type: 'every_n_days', interval: state.recurrence_interval };
|
|
case 'weekly':
|
|
return { type: 'weekly' };
|
|
case 'monthly_nth_weekday':
|
|
return { type: 'monthly_nth_weekday', week: state.recurrence_week, weekday: state.recurrence_weekday };
|
|
case 'monthly_date':
|
|
return { type: 'monthly_date', day: state.recurrence_day };
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// --- Component ---
|
|
|
|
export default function EventDetailPanel({
|
|
event,
|
|
isCreating = false,
|
|
createDefaults,
|
|
onClose,
|
|
onSaved,
|
|
onDeleted,
|
|
locationName,
|
|
myPermission,
|
|
isSharedEvent = false,
|
|
}: EventDetailPanelProps) {
|
|
const queryClient = useQueryClient();
|
|
const { data: calendars = [], sharedData: sharedMemberships = [] } = useCalendars();
|
|
const selectableCalendars = [
|
|
...calendars.filter((c) => !c.is_system),
|
|
...sharedMemberships
|
|
.filter((m) => m.permission === 'create_modify' || m.permission === 'full_access')
|
|
.map((m) => ({ id: m.calendar_id, name: m.calendar_name, color: m.local_color || m.calendar_color, is_default: false })),
|
|
];
|
|
const defaultCalendar = calendars.find((c) => c.is_default);
|
|
|
|
const { data: locations = [] } = useQuery({
|
|
queryKey: ['locations'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<LocationType[]>('/locations');
|
|
return data;
|
|
},
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
|
|
const { acquire: acquireLock, release: releaseLock, isAcquiring: isAcquiringLock } = useEventLock(
|
|
isSharedEvent && event ? (typeof event.id === 'number' ? event.id : null) : null
|
|
);
|
|
const [lockInfo, setLockInfo] = useState<EventLockInfo | null>(null);
|
|
|
|
|
|
// Event invitation hooks
|
|
const eventNumericId = event && typeof event.id === 'number' ? event.id : null;
|
|
const parentEventId = event?.parent_event_id ?? eventNumericId;
|
|
const {
|
|
invitees, invite, isInviting, respond: respondInvitation,
|
|
isResponding, override: overrideInvitation, leave: leaveInvitation, isLeaving,
|
|
} = useEventInvitations(parentEventId);
|
|
const { connections } = useConnectedUsersSearch();
|
|
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
|
|
|
|
const isInvitedEvent = !!event?.is_invited;
|
|
const myInvitationStatus = event?.invitation_status ?? null;
|
|
const myInvitationId = event?.invitation_id ?? null;
|
|
|
|
const [isEditing, setIsEditing] = useState(isCreating);
|
|
const [editState, setEditState] = useState<EditState>(() =>
|
|
isCreating
|
|
? buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '')
|
|
: event
|
|
? buildEditStateFromEvent(event)
|
|
: buildCreateState(null, defaultCalendar?.id?.toString() || '')
|
|
);
|
|
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
|
|
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
|
const [locationSearch, setLocationSearch] = useState('');
|
|
|
|
// Poll lock status in view mode for shared events (Stream A: real-time lock awareness)
|
|
// lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly.
|
|
const viewLockQuery = useQuery({
|
|
queryKey: ['event-lock', event?.id],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<EventLockInfo>(
|
|
`/shared-calendars/events/${event!.id}/lock`
|
|
);
|
|
return data;
|
|
},
|
|
enabled: !!isSharedEvent && !!event && typeof event.id === 'number' && !isEditing && !isCreating,
|
|
refetchInterval: 5_000,
|
|
refetchIntervalInBackground: true,
|
|
refetchOnMount: 'always',
|
|
});
|
|
|
|
// Clear 423-error lockInfo when poll confirms lock is gone
|
|
useEffect(() => {
|
|
if (viewLockQuery.data && !viewLockQuery.data.locked) {
|
|
setLockInfo(null);
|
|
}
|
|
}, [viewLockQuery.data]);
|
|
|
|
// Derived: authoritative lock state — poll data wins, 423 error lockInfo as fallback
|
|
const activeLockInfo: EventLockInfo | null =
|
|
(viewLockQuery.data?.locked ? viewLockQuery.data : null) ??
|
|
(lockInfo?.locked ? lockInfo : null);
|
|
|
|
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
|
|
|
|
// Permission helpers
|
|
const canEdit = !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access';
|
|
const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access';
|
|
|
|
// Reset state when event changes
|
|
useEffect(() => {
|
|
setIsEditing(false);
|
|
setScopeStep(null);
|
|
setEditScope(null);
|
|
setLocationSearch('');
|
|
setLockInfo(null);
|
|
if (event) setEditState(buildEditStateFromEvent(event));
|
|
}, [event?.id]);
|
|
|
|
// Enter edit mode when creating
|
|
useEffect(() => {
|
|
if (isCreating) {
|
|
setIsEditing(true);
|
|
setEditState(buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || ''));
|
|
setLocationSearch('');
|
|
}
|
|
}, [isCreating, createDefaults]);
|
|
|
|
// Initialize location search text from existing location
|
|
useEffect(() => {
|
|
if (isEditing && !isCreating && event?.location_id) {
|
|
const loc = locations.find((l) => l.id === event.location_id);
|
|
if (loc) setLocationSearch(loc.name);
|
|
}
|
|
}, [isEditing, isCreating, event?.location_id, locations]);
|
|
|
|
const invalidateAll = useCallback(() => {
|
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
|
}, [queryClient]);
|
|
|
|
// --- Mutations ---
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: async (data: EditState) => {
|
|
const rule = buildRecurrencePayload(data);
|
|
let endDt = data.end_datetime;
|
|
if (data.all_day && endDt) endDt = adjustAllDayEndForSave(endDt);
|
|
|
|
const payload: Record<string, unknown> = {
|
|
title: data.title,
|
|
description: data.description || null,
|
|
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 (event && !isCreating) {
|
|
if (editScope) payload.edit_scope = editScope;
|
|
return api.put(`/events/${event.id}`, payload);
|
|
} else {
|
|
return api.post('/events', payload);
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
if (isSharedEvent) releaseLock();
|
|
invalidateAll();
|
|
toast.success(isCreating ? 'Event created' : 'Event updated');
|
|
if (isCreating) {
|
|
onClose();
|
|
onSaved?.();
|
|
} else {
|
|
setIsEditing(false);
|
|
setEditScope(null);
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event'));
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const scope = editScope ? `?scope=${editScope}` : '';
|
|
await api.delete(`/events/${event!.id}${scope}`);
|
|
},
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
toast.success('Event deleted');
|
|
onClose();
|
|
onDeleted?.();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, 'Failed to delete event'));
|
|
},
|
|
});
|
|
|
|
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
|
|
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
|
|
|
|
// --- Handlers ---
|
|
|
|
const handleEditStart = async () => {
|
|
// For shared events, acquire lock first
|
|
if (isSharedEvent && event && typeof event.id === 'number') {
|
|
try {
|
|
await acquireLock();
|
|
setLockInfo(null);
|
|
} catch (err: unknown) {
|
|
if (axios.isAxiosError(err) && err.response?.status === 423) {
|
|
const data = err.response.data as { locked_by_name?: string; expires_at?: string; is_permanent?: boolean } | undefined;
|
|
setLockInfo({
|
|
locked: true,
|
|
locked_by_name: data?.locked_by_name || 'another user',
|
|
expires_at: data?.expires_at || null,
|
|
is_permanent: data?.is_permanent || false,
|
|
});
|
|
return;
|
|
}
|
|
toast.error('Failed to acquire edit lock');
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (isRecurring) {
|
|
setScopeStep('edit');
|
|
} else {
|
|
if (event) setEditState(buildEditStateFromEvent(event));
|
|
setIsEditing(true);
|
|
}
|
|
};
|
|
|
|
const handleScopeSelect = (scope: 'this' | 'this_and_future') => {
|
|
setEditScope(scope);
|
|
if (scopeStep === 'edit') {
|
|
if (event) setEditState(buildEditStateFromEvent(event));
|
|
setIsEditing(true);
|
|
setScopeStep(null);
|
|
} else if (scopeStep === 'delete') {
|
|
// Delete with scope — execute immediately
|
|
setScopeStep(null);
|
|
const scopeParam = `?scope=${scope}`;
|
|
api.delete(`/events/${event!.id}${scopeParam}`).then(() => {
|
|
invalidateAll();
|
|
toast.success('Event(s) deleted');
|
|
onClose();
|
|
onDeleted?.();
|
|
}).catch((error) => {
|
|
toast.error(getErrorMessage(error, 'Failed to delete event'));
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleEditCancel = () => {
|
|
if (isSharedEvent) releaseLock();
|
|
setIsEditing(false);
|
|
setEditScope(null);
|
|
setLocationSearch('');
|
|
if (isCreating) {
|
|
onClose();
|
|
} else if (event) {
|
|
setEditState(buildEditStateFromEvent(event));
|
|
}
|
|
};
|
|
|
|
const handleEditSave = () => {
|
|
saveMutation.mutate(editState);
|
|
};
|
|
|
|
const handleDeleteStart = () => {
|
|
if (isRecurring) {
|
|
setScopeStep('delete');
|
|
} else {
|
|
handleDeleteClick();
|
|
}
|
|
};
|
|
|
|
// --- Render helpers ---
|
|
|
|
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
|
|
setEditState((s) => ({ ...s, [key]: value }));
|
|
};
|
|
|
|
// Empty state
|
|
if (!event && !isCreating) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
<Calendar className="h-8 w-8 mb-3 opacity-40" />
|
|
<p className="text-sm">Select an event to view details</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// View mode data
|
|
const startDate = event ? parseISO(event.start_datetime) : null;
|
|
const endDate = event?.end_datetime ? parseISO(event.end_datetime) : null;
|
|
const startStr = startDate
|
|
? event!.all_day
|
|
? format(startDate, 'EEEE, MMMM d, yyyy')
|
|
: format(startDate, 'EEEE, MMMM d, yyyy · h:mm a')
|
|
: '';
|
|
const endStr = endDate
|
|
? event!.all_day
|
|
? format(endDate, 'EEEE, MMMM d, yyyy')
|
|
: format(endDate, 'h:mm a')
|
|
: null;
|
|
|
|
const panelTitle = isCreating
|
|
? createDefaults?.templateName
|
|
? `New Event from ${createDefaults.templateName}`
|
|
: 'New Event'
|
|
: event?.title || '';
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
|
<div className="flex items-start justify-between gap-3">
|
|
{isEditing ? (
|
|
<div className="flex-1 min-w-0">
|
|
{isCreating ? (
|
|
<h3 className="font-heading text-lg font-semibold">{panelTitle}</h3>
|
|
) : (
|
|
<Input
|
|
value={editState.title}
|
|
onChange={(e) => updateField('title', e.target.value)}
|
|
className="h-8 text-base font-semibold"
|
|
placeholder="Event title"
|
|
autoFocus
|
|
/>
|
|
)}
|
|
</div>
|
|
) : scopeStep ? (
|
|
<h3 className="font-heading text-sm font-semibold">
|
|
{scopeStep === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
|
|
</h3>
|
|
) : (
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
<div
|
|
className="w-3 h-3 rounded-full shrink-0"
|
|
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
|
|
/>
|
|
<div className="min-w-0">
|
|
<h3 className="font-heading text-lg font-semibold truncate">{event?.title}</h3>
|
|
<span className="text-xs text-muted-foreground">{event?.calendar_name}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{scopeStep ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => setScopeStep(null)}
|
|
title="Cancel"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
) : (isEditing || isCreating) ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-green-400 hover:text-green-300"
|
|
onClick={handleEditSave}
|
|
disabled={saveMutation.isPending}
|
|
title="Save"
|
|
>
|
|
<Save className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleEditCancel}
|
|
title="Cancel"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
{!event?.is_virtual && (
|
|
<>
|
|
{/* Edit button — only for own events or shared with edit permission */}
|
|
{canEdit && !isInvitedEvent && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleEditStart}
|
|
disabled={isAcquiringLock || !!activeLockInfo}
|
|
title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'}
|
|
>
|
|
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
)}
|
|
{/* Leave button for invited events */}
|
|
{isInvitedEvent && myInvitationId && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
onClick={() => setShowLeaveDialog(true)}
|
|
title="Leave event"
|
|
>
|
|
<LogOut className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
{/* Delete button for own events */}
|
|
{canDelete && !isInvitedEvent && (
|
|
confirmingDelete ? (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={handleDeleteStart}
|
|
disabled={deleteMutation.isPending}
|
|
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
|
title="Confirm delete"
|
|
>
|
|
Sure?
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
onClick={handleDeleteStart}
|
|
disabled={deleteMutation.isPending}
|
|
title="Delete event"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)
|
|
)}
|
|
</>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={onClose}
|
|
title="Close panel"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
|
{/* Lock banner — shown when activeLockInfo reports a lock (poll-authoritative) */}
|
|
{activeLockInfo && (
|
|
<EventLockBanner
|
|
lockedByName={activeLockInfo.locked_by_name || 'another user'}
|
|
expiresAt={activeLockInfo.expires_at}
|
|
isPermanent={activeLockInfo.is_permanent}
|
|
/>
|
|
)}
|
|
|
|
{scopeStep ? (
|
|
/* Scope selection step */
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
This is a recurring event. How would you like to proceed?
|
|
</p>
|
|
<div className="flex flex-col gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-center"
|
|
onClick={() => handleScopeSelect('this')}
|
|
>
|
|
This event only
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-center"
|
|
onClick={() => handleScopeSelect('this_and_future')}
|
|
>
|
|
This and all future events
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full justify-center"
|
|
onClick={() => setScopeStep(null)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (isEditing || isCreating) ? (
|
|
/* Edit / Create mode */
|
|
<div className="space-y-4">
|
|
{/* Title (only shown in body for create mode; edit mode has it in header) */}
|
|
{isCreating && (
|
|
<div className="space-y-1">
|
|
<Label htmlFor="panel-title" required>Title</Label>
|
|
<Input
|
|
id="panel-title"
|
|
value={editState.title}
|
|
onChange={(e) => updateField('title', e.target.value)}
|
|
placeholder="Event title"
|
|
required
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="panel-desc">Description</Label>
|
|
<Textarea
|
|
id="panel-desc"
|
|
value={editState.description}
|
|
onChange={(e) => updateField('description', e.target.value)}
|
|
placeholder="Add a description..."
|
|
rows={3}
|
|
className="text-sm resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="panel-allday"
|
|
checked={editState.all_day}
|
|
onChange={(e) => {
|
|
const checked = (e.target as HTMLInputElement).checked;
|
|
updateField('all_day', checked);
|
|
updateField('start_datetime', formatForInput(editState.start_datetime, checked, '09:00'));
|
|
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
|
|
}}
|
|
/>
|
|
<Label htmlFor="panel-allday">All day event</Label>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="panel-start" required>Start</Label>
|
|
<DatePicker
|
|
variant="input"
|
|
id="panel-start"
|
|
mode={editState.all_day ? 'date' : 'datetime'}
|
|
value={editState.start_datetime}
|
|
onChange={(v) => updateField('start_datetime', v)}
|
|
className="text-xs"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="panel-end">End</Label>
|
|
<DatePicker
|
|
variant="input"
|
|
id="panel-end"
|
|
mode={editState.all_day ? 'date' : 'datetime'}
|
|
value={editState.end_datetime}
|
|
onChange={(v) => updateField('end_datetime', v)}
|
|
className="text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="panel-calendar">Calendar</Label>
|
|
<Select
|
|
id="panel-calendar"
|
|
value={editState.calendar_id}
|
|
onChange={(e) => updateField('calendar_id', e.target.value)}
|
|
className="text-xs"
|
|
>
|
|
{selectableCalendars.map((cal) => (
|
|
<option key={cal.id} value={cal.id}>{cal.name}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="panel-location">Location</Label>
|
|
<LocationPicker
|
|
id="panel-location"
|
|
value={locationSearch}
|
|
onChange={(val) => {
|
|
setLocationSearch(val);
|
|
if (!val) updateField('location_id', '');
|
|
}}
|
|
onSelect={async (result) => {
|
|
if (result.source === 'local' && result.location_id) {
|
|
updateField('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'] });
|
|
updateField('location_id', newLoc.id.toString());
|
|
toast.success(`Location "${result.name}" created`);
|
|
} catch {
|
|
toast.error('Failed to create location');
|
|
}
|
|
}
|
|
}}
|
|
placeholder="Search location..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recurrence */}
|
|
<div className="space-y-1">
|
|
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
|
<Select
|
|
id="panel-recurrence"
|
|
value={editState.recurrence_type}
|
|
onChange={(e) => updateField('recurrence_type', e.target.value)}
|
|
className="text-xs"
|
|
>
|
|
<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>
|
|
|
|
{editState.recurrence_type === 'every_n_days' && (
|
|
<div className="space-y-1">
|
|
<Label htmlFor="panel-interval">Every how many days?</Label>
|
|
<Input
|
|
id="panel-interval"
|
|
type="number"
|
|
min={1}
|
|
max={365}
|
|
value={editState.recurrence_interval}
|
|
onChange={(e) => updateField('recurrence_interval', parseInt(e.target.value) || 1)}
|
|
className="text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{editState.recurrence_type === 'weekly' && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Repeats every week on the same day as the start date.
|
|
</p>
|
|
)}
|
|
|
|
{editState.recurrence_type === 'monthly_nth_weekday' && (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="panel-week">Week of month</Label>
|
|
<Select
|
|
id="panel-week"
|
|
value={editState.recurrence_week.toString()}
|
|
onChange={(e) => updateField('recurrence_week', parseInt(e.target.value))}
|
|
className="text-xs"
|
|
>
|
|
<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-1">
|
|
<Label htmlFor="panel-weekday">Day of week</Label>
|
|
<Select
|
|
id="panel-weekday"
|
|
value={editState.recurrence_weekday.toString()}
|
|
onChange={(e) => updateField('recurrence_weekday', parseInt(e.target.value))}
|
|
className="text-xs"
|
|
>
|
|
{WEEKDAYS.map((name, i) => (
|
|
<option key={i} value={i}>{name}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{editState.recurrence_type === 'monthly_date' && (
|
|
<div className="space-y-1">
|
|
<Label htmlFor="panel-day">Day of month</Label>
|
|
<Input
|
|
id="panel-day"
|
|
type="number"
|
|
min={1}
|
|
max={31}
|
|
value={editState.recurrence_day}
|
|
onChange={(e) => updateField('recurrence_day', parseInt(e.target.value) || 1)}
|
|
className="text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="panel-starred"
|
|
checked={editState.is_starred}
|
|
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
|
|
/>
|
|
<Label htmlFor="panel-starred">Star this event</Label>
|
|
</div>
|
|
|
|
{/* Save / Cancel buttons at bottom of form */}
|
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
|
<Button variant="outline" size="sm" onClick={handleEditCancel}>
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
|
|
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* View mode */
|
|
<>
|
|
{/* 2-column grid: Calendar, Starred, Start, End, Location, Recurrence */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{/* Calendar */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Calendar className="h-3 w-3" />
|
|
Calendar
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-2 h-2 rounded-full shrink-0"
|
|
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
|
|
/>
|
|
<span className="text-sm">{event?.calendar_name}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Starred */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Star className="h-3 w-3" />
|
|
Starred
|
|
</div>
|
|
{event?.is_starred ? (
|
|
<p className="text-sm text-amber-200/90">Starred</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">—</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Start */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Clock className="h-3 w-3" />
|
|
Start
|
|
</div>
|
|
<CopyableField value={startStr} icon={Clock} label="Start time" />
|
|
</div>
|
|
|
|
{/* End */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Clock className="h-3 w-3" />
|
|
End
|
|
</div>
|
|
{endStr ? (
|
|
<CopyableField value={endStr} icon={Clock} label="End time" />
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">—</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Location */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<MapPin className="h-3 w-3" />
|
|
Location
|
|
</div>
|
|
{locationName ? (
|
|
<CopyableField value={locationName} icon={MapPin} label="Location" />
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">—</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recurrence */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<Repeat className="h-3 w-3" />
|
|
Recurrence
|
|
</div>
|
|
{isRecurring && event?.recurrence_rule ? (
|
|
<p className="text-sm">{formatRecurrenceRule(event.recurrence_rule)}</p>
|
|
) : isRecurring ? (
|
|
<p className="text-sm">Recurring event</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">—</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description — full width */}
|
|
{event?.description && (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
<AlignLeft className="h-3 w-3" />
|
|
Description
|
|
</div>
|
|
<p className="text-sm whitespace-pre-wrap">{event.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Invitee section — view mode */}
|
|
{event && !event.is_virtual && (
|
|
<>
|
|
{/* RSVP buttons for invitees */}
|
|
{isInvitedEvent && myInvitationId && (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
Your RSVP
|
|
</div>
|
|
<RsvpButtons
|
|
currentStatus={myInvitationStatus || 'pending'}
|
|
onRespond={(status) => {
|
|
if (event.parent_event_id && eventNumericId) {
|
|
overrideInvitation({ invitationId: myInvitationId, occurrenceId: eventNumericId, status });
|
|
} else {
|
|
respondInvitation({ invitationId: myInvitationId, status });
|
|
}
|
|
}}
|
|
isResponding={isResponding}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Invitee list */}
|
|
{invitees.length > 0 && (
|
|
<InviteeList
|
|
invitees={invitees}
|
|
isRecurringChild={!!event.parent_event_id}
|
|
/>
|
|
)}
|
|
|
|
{/* Invite search for event owner/editor */}
|
|
{!isInvitedEvent && canEdit && (
|
|
<InviteSearch
|
|
connections={connections}
|
|
existingInviteeIds={new Set(invitees.map((i) => i.user_id))}
|
|
onInvite={(userIds) => invite(userIds)}
|
|
isInviting={isInviting}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Updated at */}
|
|
{event && !event.is_virtual && (
|
|
<div className="pt-2 border-t border-border">
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{formatUpdatedAt(event.updated_at)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Leave event dialog */}
|
|
{event && isInvitedEvent && myInvitationId && (
|
|
<LeaveEventDialog
|
|
open={showLeaveDialog}
|
|
onClose={() => setShowLeaveDialog(false)}
|
|
onConfirm={() => {
|
|
leaveInvitation(myInvitationId).then(() => {
|
|
setShowLeaveDialog(false);
|
|
onClose();
|
|
});
|
|
}}
|
|
eventTitle={event.title}
|
|
isRecurring={!!(event.is_recurring || event.parent_event_id)}
|
|
isLeaving={isLeaving}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|