diff --git a/app/api/shifts/[id]/route.ts b/app/api/shifts/[id]/route.ts index 397f340..cdc07a2 100644 --- a/app/api/shifts/[id]/route.ts +++ b/app/api/shifts/[id]/route.ts @@ -97,3 +97,64 @@ export async function DELETE( ); } } + +// PUT/UPDATE shift (requires write permission) +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const user = await getSessionUser(request.headers); + const body = await request.json(); + + // Fetch shift to get calendar ID + const [existingShift] = await db.select().from(shifts).where(eq(shifts.id, id)); + + if (!existingShift) { + return NextResponse.json({ error: "Shift not found" }, { status: 404 }); + } + + // Check if shift is externally synced (read-only) + if (existingShift.externalSyncId || existingShift.syncedFromExternal) { + return NextResponse.json( + { error: "Cannot edit externally synced shifts. They are read-only." }, + { status: 403 } + ); + } + + // Check write permission (works for both authenticated users and guests) + const hasAccess = await canEditCalendar(user?.id, existingShift.calendarId); + if (!hasAccess) { + return NextResponse.json( + { error: "Insufficient permissions. Write access required." }, + { status: 403 } + ); + } + + // Update the shift + const [updatedShift] = await db + .update(shifts) + .set({ + date: body.date ? new Date(body.date) : existingShift.date, + startTime: body.startTime ?? existingShift.startTime, + endTime: body.endTime ?? existingShift.endTime, + title: body.title ?? existingShift.title, + color: body.color ?? existingShift.color, + notes: body.notes ?? existingShift.notes, + isAllDay: body.isAllDay ?? existingShift.isAllDay, + presetId: body.presetId ?? existingShift.presetId, + updatedAt: new Date(), + }) + .where(eq(shifts.id, id)) + .returning(); + + return NextResponse.json(updatedShift); + } catch (error) { + console.error("Failed to update shift:", error); + return NextResponse.json( + { error: "Failed to update shift" }, + { status: 500 } + ); + } +} diff --git a/app/page.tsx b/app/page.tsx index a5b5df4..6c57879 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -33,6 +33,7 @@ import { CalendarCompareView } from "@/components/calendar-compare-view"; import { AppFooter } from "@/components/app-footer"; import { AppHeader } from "@/components/app-header"; import { DialogManager } from "@/components/dialog-manager"; +import { ShiftFormData } from "@/components/shift-sheet"; import { getCalendarDays } from "@/lib/calendar-utils"; import { formatDateToLocal, parseLocalDate } from "@/lib/date-utils"; import { findNotesForDate } from "@/lib/event-utils"; @@ -67,6 +68,7 @@ function HomeContent() { hasLoadedOnce: shiftsLoadedOnce, createShift: createShiftHook, deleteShift: deleteShiftHook, + updateShift: updateShiftHook, refetchShifts, } = useShifts(selectedCalendar); @@ -85,6 +87,7 @@ function HomeContent() { const [compareNoteCalendarId, setCompareNoteCalendarId] = useState< string | undefined >(); + const [editingShift, setEditingShift] = useState(); // Compare mode state (needs to be before useNotes hook) const [isCompareMode, setIsCompareMode] = useState(false); @@ -358,6 +361,34 @@ function HomeContent() { refetchShifts(); }; + // Handler for editing a shift from the day shifts dialog + const handleEditShiftFromDayDialog = (shift: ShiftWithCalendar) => { + setEditingShift(shift); + setSelectedDate(shift.date as Date); + dialogStates.setShowShiftDialog(true); + }; + + // Clear editing state when dialog closes + const handleShiftDialogChange = (open: boolean) => { + dialogStates.setShowShiftDialog(open); + if (!open) { + setEditingShift(undefined); + } + }; + + // Handle shift submit (create or update) + const handleShiftSubmit = async (formData: ShiftFormData) => { + if (editingShift) { + // Update existing shift + await updateShiftHook(editingShift.id, formData); + setEditingShift(undefined); + refetchShifts(); + } else { + // Create new shift + await shiftActions.handleShiftSubmit(formData); + } + }; + // Compare mode handlers const handleCompareClick = () => { setShowCompareSelector(true); @@ -897,6 +928,7 @@ function HomeContent() { onShowAllShifts={handleShowAllShifts} onShowSyncedShifts={handleShowSyncedShifts} onDeleteShift={shiftActions.handleDeleteShift} + onEditShift={handleEditShiftFromDayDialog} /> @@ -924,11 +956,12 @@ function HomeContent() { onCalendarDialogChange={dialogStates.setShowCalendarDialog} onCreateCalendar={createCalendarHook} showShiftDialog={dialogStates.showShiftDialog} - onShiftDialogChange={dialogStates.setShowShiftDialog} - onShiftSubmit={shiftActions.handleShiftSubmit} + onShiftDialogChange={handleShiftDialogChange} + onShiftSubmit={handleShiftSubmit} selectedDate={selectedDate} selectedCalendar={selectedCalendar || null} calendars={calendars} + editingShift={editingShift} showCalendarSettingsDialog={dialogStates.showCalendarSettingsDialog} onCalendarSettingsDialogChange={ dialogStates.setShowCalendarSettingsDialog @@ -951,6 +984,7 @@ function HomeContent() { selectedDayShifts={dialogStates.selectedDayShifts} locale={locale} onDeleteShiftFromDayDialog={handleDeleteShiftFromDayDialog} + onEditShiftFromDayDialog={handleEditShiftFromDayDialog} showSyncedShiftsDialog={dialogStates.showSyncedShiftsDialog} onSyncedShiftsDialogChange={dialogStates.setShowSyncedShiftsDialog} selectedSyncedShifts={dialogStates.selectedSyncedShifts} diff --git a/components/calendar-content.tsx b/components/calendar-content.tsx index 9348678..c357396 100644 --- a/components/calendar-content.tsx +++ b/components/calendar-content.tsx @@ -38,6 +38,7 @@ interface CalendarContentProps { onShowAllShifts: (date: Date, shifts: ShiftWithCalendar[]) => void; onShowSyncedShifts: (date: Date, shifts: ShiftWithCalendar[]) => void; onDeleteShift?: (id: string) => void; + onEditShift?: (shift: ShiftWithCalendar) => void; } export function CalendarContent(props: CalendarContentProps) { @@ -87,6 +88,7 @@ export function CalendarContent(props: CalendarContentProps) { onLongPress={props.onLongPress} onShowAllShifts={props.onShowAllShifts} onShowSyncedShifts={props.onShowSyncedShifts} + onEditShift={props.onEditShift} /> diff --git a/components/calendar-grid.tsx b/components/calendar-grid.tsx index fb45980..2087db0 100644 --- a/components/calendar-grid.tsx +++ b/components/calendar-grid.tsx @@ -32,6 +32,7 @@ interface CalendarGridProps { onLongPress?: (date: Date) => void; onShowAllShifts?: (date: Date, shifts: ShiftWithCalendar[]) => void; onShowSyncedShifts?: (date: Date, shifts: ShiftWithCalendar[]) => void; + onEditShift?: (shift: ShiftWithCalendar) => void; } export function CalendarGrid({ @@ -57,6 +58,7 @@ export function CalendarGrid({ onLongPress, onShowAllShifts, onShowSyncedShifts, + onEditShift, }: CalendarGridProps) { const t = useTranslations(); const pressTimerRef = useRef>({}); @@ -174,9 +176,9 @@ export function CalendarGrid({ const eventBorderStyle = dayEvents.length === 1 && !isTodayDate ? { - borderColor: dayEvents[0].color || "#3b82f6", - borderWidth: "2px", - } + borderColor: dayEvents[0].color || "#3b82f6", + borderWidth: "2px", + } : {}; return ( @@ -200,9 +202,9 @@ export function CalendarGrid({ WebkitTouchCallout: "none", ...(isHighlighted && !isTodayDate && { - backgroundColor: `${highlightColor}15`, - borderColor: `${highlightColor}40`, - }), + backgroundColor: `${highlightColor}15`, + borderColor: `${highlightColor}40`, + }), // Event border styling (overrides highlight if both present) ...eventBorderStyle, // Multi-event gradient border using background trick to support border-radius @@ -218,17 +220,15 @@ export function CalendarGrid({ className={` min-h-25 sm:min-h-28 px-1 py-1.5 sm:p-2.5 rounded-md sm:rounded-lg text-sm transition-all relative flex flex-col border sm:border-2 ${isCurrentMonth ? "text-foreground" : "text-muted-foreground/50"} - ${ - isTodayDate - ? "border-primary shadow-lg shadow-primary/20 bg-primary/5 ring-2 ring-primary/20" - : dayEvent + ${isTodayDate + ? "border-primary shadow-lg shadow-primary/20 bg-primary/5 ring-2 ring-primary/20" + : dayEvent ? "" // Event border is handled by inline style : "border-border/30 sm:border-border/50" } - ${ - isCurrentMonth - ? "hover:bg-accent cursor-pointer active:bg-accent/80 hover:border-border" - : selectedPresetId + ${isCurrentMonth + ? "hover:bg-accent cursor-pointer active:bg-accent/80 hover:border-border" + : selectedPresetId ? "cursor-not-allowed" : "cursor-pointer" } @@ -237,20 +237,18 @@ export function CalendarGrid({ `} >
{day.getDate()}
{/* Multi-indicator badge when multiple notes/events exist */} {totalNotesCount > 1 && ( { @@ -287,11 +284,10 @@ export function CalendarGrid({ {/* Display first note title if no event and only one entry - clickable if no preset selected */} {!dayEvent && dayNote && totalNotesCount === 1 && ( { if (!selectedPresetId && onNoteIconClick) { @@ -384,10 +380,10 @@ export function CalendarGrid({ const hiddenExternalCount = maxExternalShiftsToShow !== undefined ? Math.max( - 0, - sortedExternalNormalShifts.length - - maxExternalShiftsToShow - ) + 0, + sortedExternalNormalShifts.length - + maxExternalShiftsToShow + ) : 0; const totalHiddenCount = hiddenRegularCount + hiddenExternalCount; @@ -433,6 +429,7 @@ export function CalendarGrid({ shift={shift} showShiftNotes={showShiftNotes} showFullTitles={showFullTitles} + onEditShift={!selectedPresetId ? onEditShift : undefined} /> ); })} @@ -446,11 +443,10 @@ export function CalendarGrid({ // Show all shifts dialog with all day shifts onShowAllShifts?.(day, displayableShifts); }} - className={`text-[10px] sm:text-xs text-primary font-semibold text-center pt-0.5 transition-colors ${ - selectedPresetId - ? "cursor-not-allowed opacity-50" - : "hover:text-primary/80 hover:underline cursor-pointer" - }`} + className={`text-[10px] sm:text-xs text-primary font-semibold text-center pt-0.5 transition-colors ${selectedPresetId + ? "cursor-not-allowed opacity-50" + : "hover:text-primary/80 hover:underline cursor-pointer" + }`} > +{totalHiddenCount}{" "} {totalHiddenCount === 1 @@ -472,6 +468,7 @@ export function CalendarGrid({ shift={shift} showShiftNotes={showShiftNotes} showFullTitles={showFullTitles} + onEditShift={!selectedPresetId ? onEditShift : undefined} /> ))} @@ -479,15 +476,16 @@ export function CalendarGrid({ {(maxExternalShiftsToShow === undefined ? sortedExternalNormalShifts : sortedExternalNormalShifts.slice( - 0, - maxExternalShiftsToShow - ) + 0, + maxExternalShiftsToShow + ) ).map((shift) => ( ))} @@ -500,11 +498,10 @@ export function CalendarGrid({ // Show all shifts dialog with all day shifts onShowAllShifts?.(day, displayableShifts); }} - className={`text-[10px] sm:text-xs text-primary font-semibold text-center pt-0.5 transition-colors ${ - selectedPresetId - ? "cursor-not-allowed opacity-50" - : "hover:text-primary/80 hover:underline cursor-pointer" - }`} + className={`text-[10px] sm:text-xs text-primary font-semibold text-center pt-0.5 transition-colors ${selectedPresetId + ? "cursor-not-allowed opacity-50" + : "hover:text-primary/80 hover:underline cursor-pointer" + }`} > +{totalHiddenCount}{" "} {totalHiddenCount === 1 @@ -529,11 +526,10 @@ export function CalendarGrid({ e.stopPropagation(); onShowSyncedShifts?.(day, syncShifts); }} - className={`text-[10px] sm:text-xs px-1 py-0.5 sm:px-1.5 sm:py-1 rounded bg-muted/50 border border-border/50 text-muted-foreground transition-colors text-center ${ - selectedPresetId - ? "cursor-not-allowed opacity-50" - : "hover:bg-muted hover:text-foreground cursor-pointer" - }`} + className={`text-[10px] sm:text-xs px-1 py-0.5 sm:px-1.5 sm:py-1 rounded bg-muted/50 border border-border/50 text-muted-foreground transition-colors text-center ${selectedPresetId + ? "cursor-not-allowed opacity-50" + : "hover:bg-muted hover:text-foreground cursor-pointer" + }`} style={{ borderLeftColor: sync.color, borderLeftWidth: "2px", diff --git a/components/calendar-shift-card.tsx b/components/calendar-shift-card.tsx index 9332dc5..b60c04f 100644 --- a/components/calendar-shift-card.tsx +++ b/components/calendar-shift-card.tsx @@ -5,32 +5,43 @@ interface CalendarShiftCardProps { shift: ShiftWithCalendar; showShiftNotes?: boolean; showFullTitles?: boolean; + onEditShift?: (shift: ShiftWithCalendar) => void; } export function CalendarShiftCard({ shift, showShiftNotes = false, showFullTitles = false, + onEditShift, }: CalendarShiftCardProps) { const t = useTranslations(); + const isClickable = onEditShift && !shift.externalSyncId && !shift.syncedFromExternal; + + const handleClick = (e: React.MouseEvent) => { + if (isClickable) { + e.stopPropagation(); // Prevent triggering day click + onEditShift(shift); + } + }; + return (
{shift.title}
@@ -55,3 +66,4 @@ export function CalendarShiftCard({
); } + diff --git a/components/dialog-manager.tsx b/components/dialog-manager.tsx index 04b161d..723fffc 100644 --- a/components/dialog-manager.tsx +++ b/components/dialog-manager.tsx @@ -24,6 +24,7 @@ interface DialogManagerProps { selectedCalendar: string | null; onPresetsChange?: () => void; calendars: CalendarWithCount[]; + editingShift?: ShiftWithCalendar; // For editing existing shifts // Calendar Settings Dialog showCalendarSettingsDialog: boolean; @@ -48,6 +49,7 @@ interface DialogManagerProps { selectedDayShifts: ShiftWithCalendar[]; locale: string; onDeleteShiftFromDayDialog: (id: string) => void; + onEditShiftFromDayDialog?: (shift: ShiftWithCalendar) => void; // Edit shift from day dialog // Synced Shifts Overview showSyncedShiftsDialog: boolean; @@ -121,6 +123,7 @@ export function DialogManager(props: DialogManagerProps) { onOpenChange={props.onShiftDialogChange} onSubmit={props.onShiftSubmit} selectedDate={props.selectedDate} + shift={props.editingShift} onPresetsChange={props.onPresetsChange} calendarId={props.selectedCalendar || undefined} /> @@ -171,6 +174,7 @@ export function DialogManager(props: DialogManagerProps) { date={props.selectedDayDate} shifts={props.selectedDayShifts} onDeleteShift={props.onDeleteShiftFromDayDialog} + onEditShift={props.onEditShiftFromDayDialog} /> void; + onEdit?: (shift: ShiftWithCalendar) => void; } -export function ShiftCard({ shift, onDelete }: ShiftCardProps) { +export function ShiftCard({ shift, onDelete, onEdit }: ShiftCardProps) { const t = useTranslations(); + const isEditable = onEdit && !shift.externalSyncId && !shift.syncedFromExternal; + + const handleCardClick = () => { + if (isEditable) { + onEdit(shift); + } + }; + return (
@@ -49,16 +60,34 @@ export function ShiftCard({ shift, onDelete }: ShiftCardProps) {

)}
- {!shift.externalSyncId && onDelete && ( - - )} +
+ {isEditable && ( + + )} + {!shift.externalSyncId && !shift.syncedFromExternal && onDelete && ( + + )} +
); } + diff --git a/components/shifts-list.tsx b/components/shifts-list.tsx index 2c15921..fbbb763 100644 --- a/components/shifts-list.tsx +++ b/components/shifts-list.tsx @@ -12,6 +12,7 @@ interface ShiftsListProps { shifts: ShiftWithCalendar[]; currentDate: Date; onDeleteShift?: (id: string) => void; + onEditShift?: (shift: ShiftWithCalendar) => void; calendarId?: string; // Optional: if provided, uses this for permission check instead of first shift's calendarId } @@ -19,6 +20,7 @@ export function ShiftsList({ shifts, currentDate, onDeleteShift, + onEditShift, calendarId: propCalendarId, }: ShiftsListProps) { const t = useTranslations(); @@ -164,6 +166,7 @@ export function ShiftsList({ key={shift.id} shift={shift} onDelete={canEdit ? onDeleteShift : undefined} + onEdit={canEdit ? onEditShift : undefined} /> ))}
diff --git a/components/shifts-overview-dialog.tsx b/components/shifts-overview-dialog.tsx index c38ed97..616af77 100644 --- a/components/shifts-overview-dialog.tsx +++ b/components/shifts-overview-dialog.tsx @@ -13,7 +13,7 @@ import { format } from "date-fns"; import { getDateLocale } from "@/lib/locales"; import { useLocale } from "next-intl"; import { Button } from "@/components/ui/button"; -import { Trash2 } from "lucide-react"; +import { Trash2, Pencil } from "lucide-react"; interface ShiftsOverviewDialogProps { open: boolean; @@ -21,6 +21,7 @@ interface ShiftsOverviewDialogProps { date: Date | null; shifts: ShiftWithCalendar[]; onDeleteShift?: (shiftId: string) => void; + onEditShift?: (shift: ShiftWithCalendar) => void; } export function ShiftsOverviewDialog({ @@ -29,6 +30,7 @@ export function ShiftsOverviewDialog({ date, shifts, onDeleteShift, + onEditShift, }: ShiftsOverviewDialogProps) { const t = useTranslations(); const locale = useLocale(); @@ -40,6 +42,13 @@ export function ShiftsOverviewDialog({ locale: dateLocale, }); + const handleShiftClick = (shift: ShiftWithCalendar) => { + if (onEditShift && !shift.externalSyncId && !shift.syncedFromExternal) { + onOpenChange(false); // Close dialog first + onEditShift(shift); + } + }; + return ( @@ -62,8 +71,12 @@ export function ShiftsOverviewDialog({ shifts.map((shift) => (
handleShiftClick(shift)} >
@@ -88,16 +101,35 @@ export function ShiftsOverviewDialog({

)}
- {onDeleteShift && !shift.externalSyncId && ( - - )} +
+ {onEditShift && !shift.externalSyncId && !shift.syncedFromExternal && ( + + )} + {onDeleteShift && !shift.externalSyncId && !shift.syncedFromExternal && ( + + )} +
)) )} diff --git a/hooks/useShifts.ts b/hooks/useShifts.ts index 150ad1f..73aa80b 100644 --- a/hooks/useShifts.ts +++ b/hooks/useShifts.ts @@ -73,6 +73,27 @@ async function deleteShiftApi(id: string): Promise { } } +async function updateShiftApi( + id: string, + formData: ShiftFormData +): Promise { + const response = await fetch(`/api/shifts/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to update shift: ${response.status} ${response.statusText} - ${errorText}` + ); + } + + const data = await response.json(); + return normalizeShift(data); +} + // Context type for optimistic updates interface CreateShiftContext { previous: ShiftWithCalendar[] | undefined; @@ -82,6 +103,10 @@ interface DeleteShiftContext { previous: ShiftWithCalendar[] | undefined; } +interface UpdateShiftContext { + previous: ShiftWithCalendar[] | undefined; +} + export function useShifts(calendarId: string | undefined) { const t = useTranslations(); const queryClient = useQueryClient(); @@ -202,6 +227,67 @@ export function useShifts(calendarId: string | undefined) { }, }); + // Update mutation with optimistic update + const updateMutation = useMutation< + ShiftWithCalendar, + Error, + { id: string; formData: ShiftFormData }, + UpdateShiftContext + >({ + mutationFn: ({ id, formData }) => updateShiftApi(id, formData), + onMutate: async ({ id, formData }) => { + await queryClient.cancelQueries({ + queryKey: queryKeys.shifts.byCalendar(calendarId!), + }); + + const previous = queryClient.getQueryData( + queryKeys.shifts.byCalendar(calendarId!) + ); + + // Optimistically update the shift + queryClient.setQueryData( + queryKeys.shifts.byCalendar(calendarId!), + (old = []) => + old.map((s) => + s.id === id + ? { + ...s, + date: parseLocalDate(formData.date), + startTime: formData.startTime, + endTime: formData.endTime, + title: formData.title, + color: formData.color || s.color, + notes: formData.notes || null, + isAllDay: formData.isAllDay || false, + presetId: formData.presetId || null, + updatedAt: new Date(), + } + : s + ) + ); + + return { previous }; + }, + onError: (err, variables, context) => { + if (context?.previous) { + queryClient.setQueryData( + queryKeys.shifts.byCalendar(calendarId!), + context.previous + ); + } + console.error("Failed to update shift:", err); + toast.error(t("common.updateError", { item: t("shift.shift_one") })); + }, + onSuccess: () => { + toast.success(t("common.updated", { item: t("shift.shift_one") })); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.shifts.byCalendar(calendarId!), + }); + }, + }); + // Return API-compatible interface return { shifts, @@ -240,6 +326,22 @@ export function useShifts(calendarId: string | undefined) { } return deleteMutation.mutateAsync(shiftId); }, + updateShift: async (shiftId: string, formData: ShiftFormData) => { + if (!calendarId) { + throw new Error("Calendar ID is required to update a shift"); + } + // Check if shift is externally synced (read-only) + const cachedShifts = queryClient.getQueryData( + queryKeys.shifts.byCalendar(calendarId) + ); + const shift = cachedShifts?.find((s) => s.id === shiftId); + if (shift?.syncedFromExternal) { + throw new Error( + t("common.updateError", { item: t("shift.shift_one") }) + ); + } + return updateMutation.mutateAsync({ id: shiftId, formData }); + }, refetchShifts: () => { if (!calendarId) return; queryClient.invalidateQueries({