diff --git a/api b/api new file mode 100755 index 0000000..72a8d5b Binary files /dev/null and b/api differ diff --git a/client/web/app/(resa)/layout.tsx b/client/web/app/(resa)/layout.tsx index e6fc25e..51bd265 100644 --- a/client/web/app/(resa)/layout.tsx +++ b/client/web/app/(resa)/layout.tsx @@ -133,12 +133,18 @@ function ResaHeader({
{/* Left side */}
- { - // If opening left sidebar on small screen, close right sidebar - if (!leftSidebarOpen && window.innerWidth < 1024 && setRightSidebarOpen) { - setRightSidebarOpen(false); - } - }} /> + { + // If opening left sidebar on small screen, close right sidebar + if ( + !leftSidebarOpen && + window.innerWidth < 1024 && + setRightSidebarOpen + ) { + setRightSidebarOpen(false); + } + }} + /> + +
- ); + ) } diff --git a/client/web/components/calendar/calendar-grid.tsx b/client/web/components/calendar/calendar-grid.tsx index 5fe29ca..6a54253 100644 --- a/client/web/components/calendar/calendar-grid.tsx +++ b/client/web/components/calendar/calendar-grid.tsx @@ -1,30 +1,32 @@ -"use client"; - -import { useMemo, useState, useRef, useEffect } from "react"; -import type { ScheduledShift } from "@/types/schedule"; -import type { ShiftTemplate } from "@/types/shift-template"; -import type { Employee } from "@/types/employee"; -import type { Role } from "@/types/role"; -import { DayHeader } from "./day-header"; -import { TimeColumn } from "./time-column"; -import { TimeSlotCell } from "./time-slot-cell"; -import { OverlayLayer } from "./overlay-layer"; -import { CalendarClickMenu } from "./calendar-click-menu"; -import { CurrentTimeIndicator } from "./current-time-indicator"; -import { getAllHours, getWeekDates } from "@/lib/calendar/shift-utils"; -import { getTimezoneAbbreviation } from "@/lib/time"; -// Note: calculateClickPosition no longer used - using inline calculations instead -import { useRoles } from "@/hooks/use-roles"; -import { useRestaurant } from "@/contexts/restaurant-context"; -import { useShiftTemplateContext } from "@/contexts/shift-template-context"; +"use client" + +import { useMemo, useState, useRef, useEffect } from "react" +import type { ScheduledShift } from "@/types/schedule" +import type { ShiftTemplate } from "@/types/shift-template" +import type { Employee } from "@/types/employee" +import type { Role } from "@/types/role" +import { DayHeader } from "./day-header" +import { TimeColumn } from "./time-column" +import { TimeSlotCell } from "./time-slot-cell" +import { OverlayLayer } from "./overlay-layer" +import { CalendarClickMenu } from "./calendar-click-menu" +import { CurrentTimeIndicator } from "./current-time-indicator" +import { getAllHours, getWeekDates } from "@/lib/calendar/shift-utils" +import { getTimezoneAbbreviation } from "@/lib/time" +import { useRoles } from "@/hooks/use-roles" +import { useRestaurant } from "@/contexts/restaurant-context" +import { useShiftTemplateContext } from "@/contexts/shift-template-context" +import { useEvents } from "@/hooks/use-events" import { Popover, PopoverAnchor, PopoverContent, } from "@/components/ui/popover"; import { ShiftTemplateFormDialog } from "@/components/schedules/shift-template-form-dialog"; +import { EventEditDialog } from "./event-edit-dialog"; import { SelectionHighlight } from "./selection-highlight"; import { hoursToTimeString, roundToQuarterHour } from "@/lib/time"; +import type { Event } from "@/types/event" /** Constants for calendar grid calculations */ const PIXELS_PER_HOUR = 60; @@ -90,9 +92,15 @@ export function CalendarGrid({ const hours = getAllHours(); // Fetch roles once at grid level - const { selectedRestaurantId } = useRestaurant(); - const { roles, isLoading: rolesLoading } = useRoles(selectedRestaurantId); - const { refetch: refetchShiftTemplates } = useShiftTemplateContext(); + const { selectedRestaurantId } = useRestaurant() + const { roles, isLoading: rolesLoading } = useRoles(selectedRestaurantId) + const { refetch: refetchShiftTemplates } = useShiftTemplateContext() + + // Fetch events for the visible week + const { events, refetch: refetchEvents } = useEvents(selectedRestaurantId, { + startDate: weekDates[0], + endDate: weekDates[6], + }) // Create role lookup map for O(1) access const roleMap = useMemo(() => { @@ -102,13 +110,18 @@ export function CalendarGrid({ }, [roles]); // State for click-to-create popover - const [popoverOpen, setPopoverOpen] = useState(false); + const [popoverOpen, setPopoverOpen] = useState(false) const [clickPosition, setClickPosition] = useState<{ - x: number; - y: number; - side: "left" | "right"; - } | null>(null); - const [shiftDialogOpen, setShiftDialogOpen] = useState(false); + x: number + y: number + side: "left" | "right" + } | null>(null) + const [shiftDialogOpen, setShiftDialogOpen] = useState(false) + const [showEventForm, setShowEventForm] = useState(false) + + // State for event editing + const [selectedEvent, setSelectedEvent] = useState(null) + const [eventDialogOpen, setEventDialogOpen] = useState(false) // State for time selection (double-click or drag) const [selection, setSelection] = useState(null); @@ -149,16 +162,17 @@ export function CalendarGrid({ const handlePopoverOpenChange = (open: boolean) => { if (!open) { // Mark that we just closed the popover - justClosedRef.current = true; + justClosedRef.current = true // Reset the flag after a short delay setTimeout(() => { - justClosedRef.current = false; - }, 100); - // Clear selection when popover closes - setSelection(null); + justClosedRef.current = false + }, 100) + // Clear selection and event form when popover closes + setSelection(null) + setShowEventForm(false) } - setPopoverOpen(open); - }; + setPopoverOpen(open) + } /** * Convert pixel Y position to time string (HH:MM format) @@ -411,10 +425,36 @@ export function CalendarGrid({ setShiftDialogOpen(true); }; - // Handle "Create Event" button click (placeholder) + // Handle "Create Event" button click - show inline event form const handleCreateEvent = () => { - // No functionality yet - }; + setShowEventForm(true) + } + + // Handle successful event creation + const handleEventCreated = () => { + setShowEventForm(false) + setPopoverOpen(false) + setSelection(null) + refetchEvents() + } + + // Handle event form cancel + const handleEventFormCancel = () => { + setShowEventForm(false) + } + + // Handle existing event click + const handleEventClick = (event: Event) => { + setSelectedEvent(event) + setEventDialogOpen(true) + } + + // Handle event update/delete success + const handleEventUpdateSuccess = () => { + setEventDialogOpen(false) + setSelectedEvent(null) + refetchEvents() + } // Handle shift template creation success const handleShiftTemplateSuccess = () => { @@ -479,7 +519,7 @@ export function CalendarGrid({ ))}
- {/* Overlay layer for shift templates */} + {/* Overlay layer for shift templates and events */} {/* Selection highlight overlay */} @@ -531,11 +573,19 @@ export function CalendarGrid({ side={clickPosition.side} align="start" sideOffset={-0.5} - className="w-48 p-2" + className={showEventForm ? "w-80 p-0" : "w-48 p-2"} > @@ -553,6 +603,16 @@ export function CalendarGrid({ initialStartTime={selection?.startTime} initialEndTime={selection?.endTime} /> + + {/* Event Edit Dialog */} + ); } diff --git a/client/web/components/calendar/day-column-events.tsx b/client/web/components/calendar/day-column-events.tsx new file mode 100644 index 0000000..b03010c --- /dev/null +++ b/client/web/components/calendar/day-column-events.tsx @@ -0,0 +1,75 @@ +"use client" + +import { useMemo } from "react" +import type { Event } from "@/types/event" +import { EventCard } from "./event-card" +import { + filterEventsForDate, + assignColumnsToEvents, + calculateEventStyles, +} from "@/lib/calendar/event-utils" + +interface DayColumnEventsProps { + date: string + dayIndex: number + columnWidth: number + events: Event[] + onEventClick?: (event: Event) => void +} + +/** + * Renders event cards for a single day column + * Handles overlap detection and column assignment + */ +export function DayColumnEvents({ + date, + dayIndex, + columnWidth, + events, + onEventClick, +}: DayColumnEventsProps) { + // Filter events for this specific date + const eventsForDay = useMemo( + () => filterEventsForDate(events, date), + [events, date] + ) + + // Calculate column assignments for overlapping events + const columnAssignments = useMemo( + () => assignColumnsToEvents(eventsForDay), + [eventsForDay] + ) + + if (columnAssignments.length === 0) { + return null + } + + return ( +
+ {columnAssignments.map((assignment) => { + const styles = calculateEventStyles(assignment) + + return ( + e.full_name + )} + layoutProps={{ + width: parseFloat(styles.width), + offset: parseFloat(styles.left), + }} + /> + ) + })} +
+ ) +} diff --git a/client/web/components/calendar/event-card.tsx b/client/web/components/calendar/event-card.tsx new file mode 100644 index 0000000..10d3172 --- /dev/null +++ b/client/web/components/calendar/event-card.tsx @@ -0,0 +1,97 @@ +"use client" + +import type { Event } from "@/types/event" +import { calculateShiftHeight, parseTime } from "@/lib/calendar/shift-utils" +import { formatEventTimeRange } from "@/lib/calendar/event-utils" + +// Google Calendar-style lavender/purple color palette +const EVENT_COLORS = { + background: "#EDE9FE", // Light lavender (purple-100) + border: "#8B5CF6", // Purple-500 + text: "#000000", // Black + textSecondary: "#4B5563", // Gray-600 +} + +interface EventCardProps { + event: Event + onClick?: (event: Event) => void + assignedEmployeeNames?: string[] + /** + * For handling overlapping events + * width: percentage width (e.g., 50 for half width) + * offset: percentage left offset + */ + layoutProps?: { + width: number + offset: number + } +} + +export function EventCard({ + event, + onClick, + assignedEmployeeNames = [], + layoutProps, +}: EventCardProps) { + const height = calculateShiftHeight(event.start_time, event.end_time) + + // Calculate top offset based on absolute start time (hours * 60px) + const startHour = parseTime(event.start_time) + const topOffset = startHour * 60 + + const width = layoutProps?.width ?? 100 + const offset = layoutProps?.offset ?? 0 + + // Format time range for display + const timeRange = formatEventTimeRange(event.start_time, event.end_time) + + // Calculate if we have enough space to show additional content + const canShowEmployees = height >= 60 && assignedEmployeeNames.length > 0 + + return ( +
{ + e.stopPropagation() // Prevent click from passing to grid + onClick?.(event) + }} + > + {/* Event Title */} +
+ {event.title} +
+ + {/* Time Range */} +
+ {timeRange} +
+ + {/* Assigned Employees (if space allows and employees assigned) */} + {canShowEmployees && ( +
+ {assignedEmployeeNames.slice(0, 2).join(", ")} + {assignedEmployeeNames.length > 2 && + ` +${assignedEmployeeNames.length - 2}`} +
+ )} +
+ ) +} diff --git a/client/web/components/calendar/event-edit-dialog.tsx b/client/web/components/calendar/event-edit-dialog.tsx new file mode 100644 index 0000000..b8d6fdc --- /dev/null +++ b/client/web/components/calendar/event-edit-dialog.tsx @@ -0,0 +1,411 @@ +"use client" + +import { useState, useEffect } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" +import { Loader2, Trash2, CalendarIcon } from "lucide-react" +import type { Event } from "@/types/event" +import type { Employee } from "@/types/employee" +import { getApiBase } from "@/lib/api" +import { fetchWithAuth } from "@/lib/auth" +import { format } from "date-fns" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Calendar } from "@/components/ui/calendar" +import { cn } from "@/lib/utils" +import { showSuccessToast, showErrorToast } from "@/lib/utils/toast-helpers" + +const eventSchema = z.object({ + title: z.string().min(1, "Title required").max(255), + description: z.string().optional(), + date: z.date(), +}) + +type EventFormData = z.infer + +const HOURS = Array.from({ length: 24 }, (_, i) => { + const hour = String(i).padStart(2, "0") + const label = + i === 0 ? "12 AM" : i === 12 ? "12 PM" : i < 12 ? `${i} AM` : `${i - 12} PM` + return { value: hour, label } +}) + +const MINUTES = ["00", "15", "30", "45"] + +interface EventEditDialogProps { + restaurantId: number | null + event: Event | null + employees: Employee[] + isOpen: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function EventEditDialog({ + restaurantId, + event, + employees, + isOpen, + onOpenChange, + onSuccess, +}: EventEditDialogProps) { + const [isSubmitting, setIsSubmitting] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [error, setError] = useState(null) + + // Time state + const [startHour, setStartHour] = useState("09") + const [startMinute, setStartMinute] = useState("00") + const [endHour, setEndHour] = useState("10") + const [endMinute, setEndMinute] = useState("00") + + // Employees state + const [selectedEmployeeIds, setSelectedEmployeeIds] = useState([]) + const [isLoadingEmployees, setIsLoadingEmployees] = useState(false) + + const { + register, + handleSubmit, + setValue, + watch, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(eventSchema), + defaultValues: { + title: "", + description: "", + date: new Date(), + }, + }) + + const selectedDate = watch("date") + + // Fetch event details (employees) when dialog opens + useEffect(() => { + if (isOpen && event && restaurantId) { + // Set form values + setValue("title", event.title) + setValue("description", event.description) + setValue("date", new Date(event.date + "T00:00:00")) // Handle timezone safely? Usually YYYY-MM-DD + + // Set time values + const [sH, sM] = event.start_time.split(":") + const [eH, eM] = event.end_time.split(":") + setStartHour(sH) + setStartMinute(sM) + setEndHour(eH) + setEndMinute(eM) + + // Use pre-fetched employees if available, otherwise fetch + if (event.employees) { + setSelectedEmployeeIds(event.employees.map((e) => e.id)) + } else { + // Fetch assigned employees + const fetchEmployees = async () => { + setIsLoadingEmployees(true) + try { + const res = await fetchWithAuth( + `${getApiBase()}/restaurants/${restaurantId}/events/${event.id}/employees` + ) + if (res.ok) { + const assigned = await res.json() + if (Array.isArray(assigned)) { + setSelectedEmployeeIds(assigned.map((e: Employee) => e.id)) + } + } + } catch (err) { + console.error("Failed to fetch event employees", err) + } finally { + setIsLoadingEmployees(false) + } + } + fetchEmployees() + } + } + }, [isOpen, event, restaurantId, setValue]) + + const onSubmit = async (data: EventFormData) => { + if (!restaurantId || !event) return + + setIsSubmitting(true) + setError(null) + + try { + const payload = { + title: data.title.trim(), + description: data.description?.trim() || "", + date: format(data.date, "yyyy-MM-dd"), + start_time: `${startHour}:${startMinute}`, + end_time: `${endHour}:${endMinute}`, + employee_ids: selectedEmployeeIds, + } + + const res = await fetchWithAuth( + `${getApiBase()}/restaurants/${restaurantId}/events/${event.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + } + ) + + if (!res.ok) { + const text = await res.text() + throw new Error(text || "Failed to update event") + } + + showSuccessToast("Event updated successfully") + onSuccess() + onOpenChange(false) + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to update event" + setError(msg) + showErrorToast(msg) + } finally { + setIsSubmitting(false) + } + } + + const handleDelete = async () => { + if (!restaurantId || !event) return + + setIsDeleting(true) + try { + const res = await fetchWithAuth( + `${getApiBase()}/restaurants/${restaurantId}/events/${event.id}`, + { + method: "DELETE", + } + ) + + if (!res.ok) { + throw new Error("Failed to delete event") + } + + showSuccessToast("Event deleted successfully") + onSuccess() + onOpenChange(false) + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to delete event" + setError(msg) + showErrorToast(msg) + } finally { + setIsDeleting(false) + } + } + + const toggleEmployee = (employeeId: number) => { + setSelectedEmployeeIds((prev) => + prev.includes(employeeId) + ? prev.filter((id) => id !== employeeId) + : [...prev, employeeId] + ) + } + + return ( + + + + Edit Event + + Update event details and assignments. + + + +
+ {error && ( +
+ {error} +
+ )} + +
+ + + {errors.title && ( +

{errors.title.message}

+ )} +
+ +
+ + + + + + + date && setValue("date", date)} + initialFocus + /> + + +
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ +
+ +