diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index d177b0e..51b15b2 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -9,7 +9,7 @@ import interactionPlugin from '@fullcalendar/interaction'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; -import type { CalendarEvent, EventTemplate, Location as LocationType } from '@/types'; +import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { useSettings } from '@/hooks/useSettings'; import { Button } from '@/components/ui/button'; @@ -42,7 +42,7 @@ export default function CalendarPage() { const [createDefaults, setCreateDefaults] = useState(null); const { settings } = useSettings(); - const { data: calendars = [] } = useCalendars(); + const { data: calendars = [], sharedData } = useCalendars(); const [visibleSharedIds, setVisibleSharedIds] = useState>(new Set()); const calendarContainerRef = useRef(null); @@ -62,6 +62,14 @@ export default function CalendarPage() { return map; }, [locations]); + // Build permission map: calendar_id -> permission level + const permissionMap = useMemo(() => { + const map = new Map(); + calendars.forEach((cal) => map.set(cal.id, 'owner')); + sharedData.forEach((m) => map.set(m.calendar_id, m.permission)); + return map; + }, [calendars, sharedData]); + // Handle navigation state from dashboard useEffect(() => { const state = location.state as { date?: string; view?: string; eventId?: number } | null; @@ -139,6 +147,9 @@ export default function CalendarPage() { [selectedEventId, events], ); + const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null; + const selectedEventIsShared = selectedEvent ? permissionMap.has(selectedEvent.calendar_id) && permissionMap.get(selectedEvent.calendar_id) !== 'owner' : false; + // Escape key closes detail panel useEffect(() => { if (!panelOpen) return; @@ -498,6 +509,8 @@ export default function CalendarPage() { onClose={handlePanelClose} onSaved={handlePanelClose} locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} + myPermission={selectedEventPermission} + isSharedEvent={selectedEventIsShared} /> @@ -520,6 +533,8 @@ export default function CalendarPage() { onClose={handlePanelClose} onSaved={handlePanelClose} locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} + myPermission={selectedEventPermission} + isSharedEvent={selectedEventIsShared} /> diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index 882a0bf..8122dab 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -3,14 +3,16 @@ 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, + X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2, } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; -import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types'; +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 { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; +import EventLockBanner from './EventLockBanner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DatePicker } from '@/components/ui/date-picker'; @@ -126,6 +128,8 @@ interface EventDetailPanelProps { onSaved?: () => void; onDeleted?: () => void; locationName?: string; + myPermission?: CalendarPermission | 'owner' | null; + isSharedEvent?: boolean; } interface EditState { @@ -218,6 +222,8 @@ export default function EventDetailPanel({ onSaved, onDeleted, locationName, + myPermission, + isSharedEvent = false, }: EventDetailPanelProps) { const queryClient = useQueryClient(); const { data: calendars = [] } = useCalendars(); @@ -233,6 +239,11 @@ export default function EventDetailPanel({ 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(null); + const [isEditing, setIsEditing] = useState(isCreating); const [editState, setEditState] = useState(() => isCreating @@ -247,12 +258,17 @@ export default function EventDetailPanel({ 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]); @@ -307,6 +323,7 @@ export default function EventDetailPanel({ } }, onSuccess: () => { + if (isSharedEvent) releaseLock(); invalidateAll(); toast.success(isCreating ? 'Event created' : 'Event updated'); if (isCreating) { @@ -343,7 +360,30 @@ export default function EventDetailPanel({ // --- Handlers --- - const handleEditStart = () => { + const handleEditStart = async () => { + // For shared events, acquire lock first (owners skip locking) + if (isSharedEvent && myPermission !== 'owner' && event && typeof event.id === 'number') { + try { + await acquireLock(); + setLockInfo(null); + } catch (err: unknown) { + if (err && typeof err === 'object' && 'response' in err) { + const axErr = err as { response?: { status?: number; data?: { detail?: string; locked_by_name?: string; expires_at?: string; is_permanent?: boolean } } }; + if (axErr.response?.status === 423) { + setLockInfo({ + locked: true, + locked_by_name: axErr.response.data?.locked_by_name || 'another user', + expires_at: axErr.response.data?.expires_at || null, + is_permanent: axErr.response.data?.is_permanent || false, + }); + return; + } + } + toast.error('Failed to acquire edit lock'); + return; + } + } + if (isRecurring) { setScopeStep('edit'); } else { @@ -361,8 +401,6 @@ export default function EventDetailPanel({ } else if (scopeStep === 'delete') { // Delete with scope — execute immediately setScopeStep(null); - // The deleteMutation will read editScope, but we need to set it first - // Since setState is async, use the mutation directly with the scope const scopeParam = `?scope=${scope}`; api.delete(`/events/${event!.id}${scopeParam}`).then(() => { invalidateAll(); @@ -376,6 +414,7 @@ export default function EventDetailPanel({ }; const handleEditCancel = () => { + if (isSharedEvent) releaseLock(); setIsEditing(false); setEditScope(null); setLocationSearch(''); @@ -507,37 +546,42 @@ export default function EventDetailPanel({ <> {!event?.is_virtual && ( <> - - {confirmingDelete ? ( - - ) : ( + {canEdit && ( )} + {canDelete && ( + confirmingDelete ? ( + + ) : ( + + ) + )} )}