diff --git a/Makefile b/Makefile index 7718a6d..8461017 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,7 @@ seed: gen-docs: @swag init -g ./api/main.go -d cmd,internal && swag fmt -.PHONY: test -test: - @go test -v ./... \ No newline at end of file +# TODO: Not Funtional RN +# .PHONY: test +# test: +# @go test -v ./... \ No newline at end of file diff --git a/client/web/components/calendar/calendar-grid.tsx b/client/web/components/calendar/calendar-grid.tsx index d00c22d..5fe29ca 100644 --- a/client/web/components/calendar/calendar-grid.tsx +++ b/client/web/components/calendar/calendar-grid.tsx @@ -13,10 +13,7 @@ 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 { - calculateClickPosition, - type CalendarClickPosition, -} from "@/lib/calendar/click-to-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"; @@ -26,6 +23,35 @@ import { PopoverContent, } from "@/components/ui/popover"; import { ShiftTemplateFormDialog } from "@/components/schedules/shift-template-form-dialog"; +import { SelectionHighlight } from "./selection-highlight"; +import { hoursToTimeString, roundToQuarterHour } from "@/lib/time"; + +/** Constants for calendar grid calculations */ +const PIXELS_PER_HOUR = 60; +const TIME_COLUMN_WIDTH = 80; +const MIN_SELECTION_PIXELS = 15; // Minimum 15 minutes +const MAX_GRID_HEIGHT = 24 * PIXELS_PER_HOUR; // 1440px for 24 hours +const DEFAULT_SHIFT_DURATION_PIXELS = 2 * PIXELS_PER_HOUR; // 2 hours = 120px + +/** Selection state for completed selections (double-click or drag) */ +interface SelectionState { + type: "double-click" | "drag"; + dayIndex: number; + date: string; + startY: number; + endY: number; + startTime: string; + endTime: string; +} + +/** Drag state during active drag operation */ +interface DragState { + isDragging: boolean; + startY: number; + currentY: number; + dayIndex: number; + date: string; +} interface CalendarGridProps { weekStartDate: string; // YYYY-MM-DD format (Sunday) @@ -80,12 +106,14 @@ export function CalendarGrid({ const [clickPosition, setClickPosition] = useState<{ x: number; y: number; + side: "left" | "right"; } | null>(null); - const [clickData, setClickData] = useState( - null - ); const [shiftDialogOpen, setShiftDialogOpen] = useState(false); + // State for time selection (double-click or drag) + const [selection, setSelection] = useState(null); + const [dragState, setDragState] = useState(null); + // Refs for calculating click position const scrollContainerRef = useRef(null); const gridRef = useRef(null); @@ -126,12 +154,78 @@ export function CalendarGrid({ setTimeout(() => { justClosedRef.current = false; }, 100); + // Clear selection when popover closes + setSelection(null); } setPopoverOpen(open); }; - // Handle click on empty calendar area - const handleGridClick = (e: React.MouseEvent) => { + /** + * Convert pixel Y position to time string (HH:MM format) + */ + const pixelsToTime = (y: number): string => { + const hours = roundToQuarterHour(y / PIXELS_PER_HOUR); + return hoursToTimeString(Math.min(hours, 23.75)); + }; + + /** + * Snap Y position to 15-minute increments (15px) + */ + const snapToGrid = (y: number): number => { + return Math.round(y / MIN_SELECTION_PIXELS) * MIN_SELECTION_PIXELS; + }; + + /** + * Calculate day index from X position + */ + const getDayIndexFromX = (clientX: number, gridRect: DOMRect): number => { + const relativeX = clientX - gridRect.left; + const dayIndex = Math.floor(relativeX / columnWidth); + return Math.max(0, Math.min(6, dayIndex)); + }; + + /** + * Calculate popover anchor position and side based on selection. + * Returns position at the edge of the selection highlight and determines + * if popover should show on left or right based on available space. + */ + const calculatePopoverPosition = ( + dayIndex: number, + startY: number, + endY: number, + gridRect: DOMRect + ): { x: number; y: number; side: "left" | "right" } => { + const POPOVER_WIDTH = 192; // w-48 = 192px + const BUFFER = 16; // Small buffer from edge + + // Calculate selection highlight bounds within the grid + // Note: gridRect is already positioned after the time column + const selectionLeft = dayIndex * columnWidth; + const selectionRight = (dayIndex + 1) * columnWidth; + const selectionTop = Math.min(startY, endY); + + // Calculate available space on right side + const gridWidth = gridRect.width; + const spaceOnRight = gridWidth - selectionRight; + + // Determine side: use right if enough space, otherwise left + const side = spaceOnRight >= POPOVER_WIDTH + BUFFER ? "right" : "left"; + + // Position anchor at the appropriate edge + const anchorX = + side === "right" + ? gridRect.left + selectionRight + : gridRect.left + selectionLeft; + + const anchorY = gridRect.top + selectionTop; + + return { x: anchorX, y: anchorY, side }; + }; + + /** + * Handle double-click on calendar grid - creates 2-hour selection + */ + const handleGridDoubleClick = (e: React.MouseEvent) => { // Don't trigger if clicking on overlay elements (shift templates) if ((e.target as HTMLElement).closest("[data-overlay]")) { return; @@ -142,37 +236,175 @@ export function CalendarGrid({ return; } - // Don't open new popover if we just closed one - if (justClosedRef.current) { + const grid = gridRef.current; + if (!grid) return; + + const gridRect = grid.getBoundingClientRect(); + + // Calculate position - gridRect.top already accounts for scroll position + const relativeY = e.clientY - gridRect.top; + const startY = snapToGrid(relativeY); + const endY = Math.min( + startY + DEFAULT_SHIFT_DURATION_PIXELS, + MAX_GRID_HEIGHT + ); + + const dayIndex = getDayIndexFromX(e.clientX, gridRect); + + // Calculate times + const startTime = pixelsToTime(startY); + const endTime = pixelsToTime(endY); + + setSelection({ + type: "double-click", + dayIndex, + date: weekDates[dayIndex], + startY, + endY, + startTime, + endTime, + }); + + const position = calculatePopoverPosition(dayIndex, startY, endY, gridRect); + setClickPosition(position); + setPopoverOpen(true); + }; + + /** + * Handle mouse down on calendar grid - start drag selection + */ + const handleGridMouseDown = (e: React.MouseEvent) => { + // Don't start drag on overlay elements + if ((e.target as HTMLElement).closest("[data-overlay]")) { return; } - // If popover is already open, close it + // Don't start drag inside a popover + if ((e.target as HTMLElement).closest("[data-slot='popover-content']")) { + return; + } + + // Don't start if popover is open if (popoverOpen) { - setPopoverOpen(false); return; } - const scrollContainer = scrollContainerRef.current; const grid = gridRef.current; - if (!scrollContainer || !grid) return; + if (!grid) return; + + const gridRect = grid.getBoundingClientRect(); + + // Calculate position - gridRect.top already accounts for scroll position + const relativeY = e.clientY - gridRect.top; + const snappedY = snapToGrid(relativeY); + const dayIndex = getDayIndexFromX(e.clientX, gridRect); + + // Clear any existing selection and start drag + setSelection(null); + setDragState({ + isDragging: true, + startY: snappedY, + currentY: snappedY, + dayIndex, + date: weekDates[dayIndex], + }); + }; + + /** + * Handle mouse move on calendar grid - update drag selection + */ + const handleGridMouseMove = (e: React.MouseEvent) => { + if (!dragState?.isDragging) return; + + const grid = gridRef.current; + if (!grid) return; const gridRect = grid.getBoundingClientRect(); - const scrollTop = scrollContainer.scrollTop; - - const data = calculateClickPosition({ - clientX: e.clientX, - clientY: e.clientY, - gridRect, - scrollTop, - weekDates, + + // Calculate Y position and clamp to grid bounds + // gridRect.top already accounts for scroll position + const relativeY = e.clientY - gridRect.top; + const clampedY = Math.max(0, Math.min(MAX_GRID_HEIGHT, relativeY)); + const snappedY = snapToGrid(clampedY); + + setDragState((prev) => (prev ? { ...prev, currentY: snappedY } : null)); + }; + + /** + * Handle mouse up on calendar grid - complete drag selection + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleGridMouseUp = (_e: React.MouseEvent) => { + if (!dragState?.isDragging) return; + + const { startY, currentY, dayIndex, date } = dragState; + + // Calculate actual start/end (handle dragging upward) + const actualStartY = Math.min(startY, currentY); + const actualEndY = Math.max(startY, currentY); + + // Clear drag state + setDragState(null); + + // Minimum selection: 15px (15 minutes) + if (actualEndY - actualStartY < MIN_SELECTION_PIXELS) { + return; + } + + // Calculate times + const startTime = pixelsToTime(actualStartY); + const endTime = pixelsToTime(actualEndY); + + setSelection({ + type: "drag", + dayIndex, + date, + startY: actualStartY, + endY: actualEndY, + startTime, + endTime, }); - setClickData(data); - setClickPosition({ x: e.clientX, y: e.clientY }); + const grid = gridRef.current; + if (grid) { + const gridRect = grid.getBoundingClientRect(); + const position = calculatePopoverPosition( + dayIndex, + actualStartY, + actualEndY, + gridRect + ); + setClickPosition(position); + } setPopoverOpen(true); }; + // Handle mouse up outside the grid (global listener) + useEffect(() => { + const handleGlobalMouseUp = () => { + if (dragState?.isDragging) { + setDragState(null); + } + }; + + window.addEventListener("mouseup", handleGlobalMouseUp); + return () => window.removeEventListener("mouseup", handleGlobalMouseUp); + }, [dragState?.isDragging]); + + // Handle Escape key to clear selection + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setSelection(null); + setPopoverOpen(false); + setDragState(null); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + // Handle "Create Shift" button click const handleCreateShift = () => { setPopoverOpen(false); @@ -187,7 +419,7 @@ export function CalendarGrid({ // Handle shift template creation success const handleShiftTemplateSuccess = () => { setShiftDialogOpen(false); - setClickData(null); + setSelection(null); refetchShiftTemplates(); onShiftCreated?.(); }; @@ -226,8 +458,13 @@ export function CalendarGrid({ {/* Grid background layer - just borders */}
{weekDates.map((date) => (
@@ -260,6 +497,23 @@ export function CalendarGrid({ rolesLoading={rolesLoading} /> + {/* Selection highlight overlay */} + {(selection || dragState?.isDragging) && ( + + )} + {/* Current time indicator line */}
); diff --git a/client/web/components/calendar/selection-highlight.tsx b/client/web/components/calendar/selection-highlight.tsx new file mode 100644 index 0000000..a3326b3 --- /dev/null +++ b/client/web/components/calendar/selection-highlight.tsx @@ -0,0 +1,52 @@ +"use client"; + +/** + * Visual highlight overlay for calendar time selection. + * Shows a colored overlay when user double-clicks or drags to select a time range. + */ + +interface SelectionHighlightProps { + /** Day index (0-6, Sunday-Saturday) */ + dayIndex: number; + /** Width of each day column in pixels */ + columnWidth: number; + /** Start Y position in pixels (relative to grid top) */ + startY: number; + /** End Y position in pixels (relative to grid top) */ + endY: number; + /** Width of the time column in pixels */ + timeColumnWidth?: number; +} + +const SELECTION_COLOR = "rgba(192, 238, 211, 0.4)"; + +export function SelectionHighlight({ + dayIndex, + columnWidth, + startY, + endY, + timeColumnWidth = 80, +}: SelectionHighlightProps) { + // Calculate actual start/end (handle dragging upward) + const actualStartY = Math.min(startY, endY); + const actualEndY = Math.max(startY, endY); + const height = actualEndY - actualStartY; + + // Don't render if height is too small + if (height < 5) return null; + + return ( +
+ ); +} diff --git a/client/web/components/schedules/shift-template-form-dialog.tsx b/client/web/components/schedules/shift-template-form-dialog.tsx index 8efbe1f..a646cf0 100644 --- a/client/web/components/schedules/shift-template-form-dialog.tsx +++ b/client/web/components/schedules/shift-template-form-dialog.tsx @@ -25,11 +25,12 @@ import { } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; -import { X, ChevronsUpDown } from "lucide-react"; +import { X, ChevronsUpDown, Plus } from "lucide-react"; import { useShiftTemplateForm } from "@/hooks/use-shift-template-form"; import { getApiBase } from "@/lib/api"; import { fetchWithAuth } from "@/lib/auth"; import type { Role } from "@/types/role"; +import { RoleFormDialog } from "@/components/roles/role-form-dialog"; interface ShiftTemplateFormDialogProps { mode?: "create" | "edit"; @@ -149,10 +150,10 @@ export function ShiftTemplateFormDialog({ // State for role selection const [availableRoles, setAvailableRoles] = useState([]); const [selectedRoles, setSelectedRoles] = useState([]); - const [roleSelectValue, setRoleSelectValue] = useState(""); const [rolesLoading, setRolesLoading] = useState(false); const [rolesError, setRolesError] = useState(null); const [rolesValidationError, setRolesValidationError] = useState(null); + const [showRoleDialog, setShowRoleDialog] = useState(false); // Submit button text (defined after state to access isCreating and selectedDays) const submitButtonText = isCreating @@ -255,20 +256,62 @@ export function ShiftTemplateFormDialog({ } }, [dialogOpen]); - // Handlers for role selection - const handleRoleSelect = (value: string) => { - const role = availableRoles.find((r) => r.id === parseInt(value)); - if (role && !selectedRoles.find((r) => r.id === role.id)) { - setSelectedRoles([...selectedRoles, role]); - setRoleSelectValue(""); // Reset dropdown after adding role - setRolesValidationError(null); // Clear validation error when role added + // Apply initial values when dialog opens + useEffect(() => { + if (dialogOpen) { + // Apply initial day of week + if (initialDayOfWeek !== undefined) { + setSelectedDays([initialDayOfWeek]); + } + + // Apply initial start time + if (initialStartTime) { + const [hour, minute] = initialStartTime.split(":"); + setStartHour(hour); + setStartMinute(minute); + setValue("start_time", initialStartTime); + } + + // Apply initial end time + if (initialEndTime) { + const [hour, minute] = initialEndTime.split(":"); + setEndHour(hour); + setEndMinute(minute); + setValue("end_time", initialEndTime); + } } - }; + }, [dialogOpen, initialDayOfWeek, initialStartTime, initialEndTime, setValue]); + // Handler for role removal const handleRoleRemove = (roleId: number) => { setSelectedRoles(selectedRoles.filter((r) => r.id !== roleId)); }; + // Handler for role creation + const handleRoleCreated = async (newRole: unknown) => { + // Refetch roles to get the latest list from backend + await fetchRoles(); + + // Extract role from either direct or wrapped response + const role = (newRole && typeof newRole === 'object' && 'data' in newRole) + ? (newRole as Record).data + : newRole; + + // Automatically select the newly created role + if (role && typeof role === 'object' && 'id' in role) { + const roleObj = role as Role; + setSelectedRoles(prev => { + // Only add if not already selected + if (prev.some(r => r.id === roleObj.id)) { + return prev; + } + return [...prev, roleObj]; + }); + } + + setShowRoleDialog(false); + }; + // Custom submit handler for multi-day creation const handleMultiDaySubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -378,13 +421,14 @@ export function ShiftTemplateFormDialog({ }; return ( - - - - {dialogTitle} - {dialogDescription} - -
+ <> + + + + {dialogTitle} + {dialogDescription} + + {(submissionError || error || isLoading) && (
{submissionError || error || "Loading shift template details..."} @@ -483,37 +527,68 @@ export function ShiftTemplateFormDialog({ )} - {/* Roles Selection (Multi-select) */} + {/* Roles Selection (Multi-select with Popover) */} Roles - + + Create new role + +
+ + + {/* Error messages */} {rolesError && (

{rolesError}

)} @@ -629,5 +704,15 @@ export function ShiftTemplateFormDialog({
+ + {/* Role Creation Dialog */} + + ); } diff --git a/cmd/api/main.go b/cmd/api/main.go index bdc73c3..8daf51d 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -82,7 +82,7 @@ func main() { google: googleOAuthConfig{ clientID: env.GetString("GOOGLE_CLIENT_ID", ""), clientSecret: env.GetString("GOOGLE_CLIENT_SECRET", ""), - redirectURL: env.GetString("GOOGLE_REDIRECT_URL", "http://localhost:8080/v1/authentication/google/callback"), + redirectURL: env.GetString("GOOGLE_REDIRECT_URL", "http://localhost:3000/auth/google/callback"), }, }, rateLimiter: ratelimiter.Config{