From 91b2660fd6a26097de695a7ad4a1f1d81c63b054 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sat, 6 Dec 2025 19:57:48 -0800 Subject: [PATCH 1/5] fix: time issue and rendering of current-time-indicator.tsx --- GEMINI.md | 90 +++++++++++++++++++ .../calendar/current-time-indicator.tsx | 6 +- client/web/hooks/use-schedule.tsx | 36 ++------ client/web/lib/time/index.ts | 34 +++++-- 4 files changed, 129 insertions(+), 37 deletions(-) create mode 100644 GEMINI.md diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..eff3949 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +RESA (Restaurant Employee Scheduling Application) is a full-stack scheduling system with a Go backend API and Next.js 15 React frontend. It enables restaurant managers to create schedules, manage employees and roles, and assign shifts. + +## Development Commands + +### Backend (Go) +```bash +# Run with hot reload (requires Air installed) +air + +# Or manual run +go run cmd/api/main.go + +# Generate Swagger docs (run before commits if API changed) +make gen-docs + +# Run tests +make test + +# Database migrations +make migrate-up # Apply pending migrations +make migrate-down # Rollback last migration +make migrate-create [name] # Create new migration files +make seed # Seed database with test data +``` + +### Frontend (Next.js) +```bash +cd client/web +npm run dev # Development server with Turbopack (port 3000) +npm run build # Production build +npm run lint # ESLint +``` + +### Infrastructure +```bash +docker-compose up -d # Start PostgreSQL + Redis containers +# PostgreSQL: localhost:5432, Redis: localhost:6379 +``` + +## Architecture + +### Backend Structure +- `cmd/api/` - HTTP handlers, routes, middleware. Each resource has its own handler file (employees.go, roles.go, schedules.go, etc.) +- `cmd/migrate/migrations/` - SQL migration files (numbered sequentially) +- `internal/store/` - Database layer with repository pattern. `storage.go` defines interfaces, other files implement them +- `internal/auth/` - JWT authentication and Google OAuth +- `internal/store/cache/` - Redis caching layer (optional, controlled by REDIS_ENABLED) + +### Frontend Structure +- `client/web/app/` - Next.js App Router with route groups: `(auth)` for login/signup, `(marketing)` for landing page, `(resa)` for protected app +- `client/web/components/` - Feature-organized components (calendar/, employees/, roles/, schedules/) +- `client/web/components/ui/` - shadcn/ui component library +- `client/web/hooks/` - Custom React hooks for data fetching and state +- `client/web/lib/auth.tsx` - Auth context and token management +- `client/web/types/` - TypeScript type definitions + +### API Pattern +Routes defined in `cmd/api/api.go`. Protected routes nest under `/v1/restaurants/{restaurantID}/`: +- `/roles`, `/employees`, `/shift-templates`, `/schedules` +- Each schedule has `/shifts` for individual shift management + +Handlers follow pattern: read request → validate → call store → return JSON response. + +### Database Models +Core entities: users, restaurants, employees, roles (with colors), shift_templates (recurring), schedules (weekly), scheduled_shifts (individual assignments). + +Custom Go types in `internal/store/types.go`: `TimeOfDay` for PostgreSQL TIME, `DateOnly` for DATE columns. + +### Frontend Data Flow +React Context for global state (`AuthProvider`, `RestaurantContext`). Custom hooks (`useEmployees`, `useRoles`, `useSchedules`) handle API calls. Forms use react-hook-form with Zod validation. + +## Key Conventions + +- API endpoints use kebab-case: `/shift-templates`, `/scheduled-shifts` +- Go handlers named `{action}{Resource}Handler` (e.g., `createEmployeeHandler`) +- Frontend hooks prefixed with `use` (e.g., `useEmployeeForm`) +- Swagger decorators required on all API handlers for doc generation +- Restaurant context middleware loads restaurant for all nested routes automatically + +## Environment Files + +Backend: `.env` (DB_ADDR, AUTH_TOKEN_SECRET, GOOGLE_CLIENT_ID/SECRET, SENDGRID_API_KEY, CORS_ALLOWED_ORIGIN) + +Frontend: `client/web/.env.local` (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_GOOGLE_MAPS_API_KEY) diff --git a/client/web/components/calendar/current-time-indicator.tsx b/client/web/components/calendar/current-time-indicator.tsx index 7e039e1..43314f0 100644 --- a/client/web/components/calendar/current-time-indicator.tsx +++ b/client/web/components/calendar/current-time-indicator.tsx @@ -35,7 +35,11 @@ export function CurrentTimeIndicator({ }, []); // Calculate today's index in the week (-1 if not visible) - const todayStr = currentTime.toISOString().split("T")[0]; + const todayYear = currentTime.getFullYear(); + const todayMonth = String(currentTime.getMonth() + 1).padStart(2, "0"); + const todayDay = String(currentTime.getDate()).padStart(2, "0"); + const todayStr = `${todayYear}-${todayMonth}-${todayDay}`; + const todayIndex = weekDates.findIndex( (date) => normalizeDate(date) === todayStr ); diff --git a/client/web/hooks/use-schedule.tsx b/client/web/hooks/use-schedule.tsx index 09fd124..8025f3e 100644 --- a/client/web/hooks/use-schedule.tsx +++ b/client/web/hooks/use-schedule.tsx @@ -4,6 +4,12 @@ import { useEffect, useState, useMemo } from "react"; import { fetchWithAuth } from "@/lib/auth"; import { getApiBase } from "@/lib/api"; import type { Schedule, ScheduledShift } from "@/types/schedule"; +import { + getWeekEndDate, + getWeekStart, + navigateWeek as navigateWeekUtil +} from "@/lib/time"; + export interface UseScheduleReturn { schedule: Schedule | null; @@ -265,42 +271,16 @@ export function useSchedule( }; } -/** - * Get the end date of a week (Saturday) given the start date (Sunday) - */ -function getWeekEndDate(startDate: string): string { - const date = new Date(startDate + "T00:00:00"); - date.setDate(date.getDate() + 6); - return formatDate(date); -} - -/** - * Format a Date object as YYYY-MM-DD - */ -function formatDate(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} - /** * Get the Sunday of the current week */ export function getCurrentWeekStart(): string { - const today = new Date(); - const dayOfWeek = today.getDay(); // 0 = Sunday, 1 = Monday, etc. - const sunday = new Date(today); - sunday.setDate(today.getDate() - dayOfWeek); - return formatDate(sunday); + return getWeekStart(); } /** * Navigate to previous or next week */ export function navigateWeek(currentWeekStart: string, direction: "prev" | "next"): string { - const date = new Date(currentWeekStart + "T00:00:00"); - const offset = direction === "next" ? 7 : -7; - date.setDate(date.getDate() + offset); - return formatDate(date); + return navigateWeekUtil(currentWeekStart, direction); } diff --git a/client/web/lib/time/index.ts b/client/web/lib/time/index.ts index 3d2c2a5..6a5724f 100644 --- a/client/web/lib/time/index.ts +++ b/client/web/lib/time/index.ts @@ -21,6 +21,17 @@ export const DATE_FORMAT = "YYYY-MM-DD"; /** Suffix appended to dates for full ISO 8601 format */ export const ISO_DATE_SUFFIX = "T00:00:00Z"; +/** + * Format a Date object to YYYY-MM-DD in the local timezone. + * Replaces toISOString().split('T')[0] which forces UTC. + */ +function toLocalDateString(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + // ============ PARSING ============ /** @@ -326,13 +337,16 @@ export function getDayNameFull(dayIndex: number): string { */ export function getWeekDates(startDate: string): string[] { const normalized = normalizeDate(startDate); - const start = new Date(normalized + "T00:00:00"); + // Create date in local time using year/month/day constructor to avoid UTC assumptions + const [y, m, d] = normalized.split('-').map(Number); + const start = new Date(y, m - 1, d); + const dates: string[] = []; for (let i = 0; i < 7; i++) { const date = new Date(start); date.setDate(start.getDate() + i); - dates.push(date.toISOString().split("T")[0]); + dates.push(toLocalDateString(date)); } return dates; @@ -346,10 +360,12 @@ export function getWeekDates(startDate: string): string[] { */ export function getWeekEndDate(startDate: string): string { const normalized = normalizeDate(startDate); - const start = new Date(normalized + "T00:00:00"); + const [y, m, d] = normalized.split('-').map(Number); + const start = new Date(y, m - 1, d); + const end = new Date(start); end.setDate(start.getDate() + 6); - return end.toISOString().split("T")[0]; + return toLocalDateString(end); } /** @@ -364,7 +380,7 @@ export function getWeekStart(date?: Date): string { const diff = d.getDate() - day; const sunday = new Date(d); sunday.setDate(diff); - return sunday.toISOString().split("T")[0]; + return toLocalDateString(sunday); } /** @@ -379,10 +395,12 @@ export function navigateWeek( direction: "next" | "prev" ): string { const normalized = normalizeDate(currentWeekStart); - const current = new Date(normalized + "T00:00:00"); + const [y, m, d] = normalized.split('-').map(Number); + const current = new Date(y, m - 1, d); + const offset = direction === "next" ? 7 : -7; current.setDate(current.getDate() + offset); - return current.toISOString().split("T")[0]; + return toLocalDateString(current); } /** @@ -393,7 +411,7 @@ export function navigateWeek( */ export function isToday(dateStr: string): boolean { const normalized = normalizeDate(dateStr); - const today = new Date().toISOString().split("T")[0]; + const today = toLocalDateString(new Date()); return normalized === today; } From 85cef3def9a924ee23475849c4ad27736a0e0bec Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sat, 6 Dec 2025 20:39:52 -0800 Subject: [PATCH 2/5] add: toast messages for crud actions --- client/web/app/globals.css | 6 +- .../calendar/day-column-overlay.tsx | 25 +- .../employees/employee-detail-sheet.tsx | 9 +- .../employees/employee-form-dialog.tsx | 7 + client/web/components/layout/sidebar-left.tsx | 2 +- .../components/roles/role-detail-sheet.tsx | 3 + .../web/components/roles/role-form-dialog.tsx | 12 +- .../schedules/shift-template-form-dialog.tsx | 788 +++++++++++------- .../workspaces/workspace-form-dialog.tsx | 3 + client/web/hooks/use-employee-delete.tsx | 5 +- client/web/hooks/use-role-delete.tsx | 2 + client/web/hooks/use-shift-creation-flow.tsx | 2 +- client/web/hooks/use-shift-template-form.tsx | 33 + client/web/hooks/use-workspace-delete.tsx | 5 +- client/web/hooks/use-workspace-form.tsx | 5 +- 15 files changed, 595 insertions(+), 312 deletions(-) diff --git a/client/web/app/globals.css b/client/web/app/globals.css index 68791fc..2f6dbdb 100644 --- a/client/web/app/globals.css +++ b/client/web/app/globals.css @@ -58,8 +58,8 @@ --accent: oklch(0.968 0.007 247.896); --accent-foreground: oklch(0.208 0.042 265.755); --destructive: oklch(0.28 0.15 25); - --border: oklch(0.929 0.013 255.508); - --input: oklch(0.929 0.013 255.508); + --border: oklch(0.937 0 0); + --input: oklch(0.937 0 0); --ring: oklch(0.704 0.04 256.788); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); @@ -72,7 +72,7 @@ --sidebar-primary-foreground: oklch(97.89% 0.001 106.42); --sidebar-accent: oklch(0.968 0.007 247.896); --sidebar-accent-foreground: oklch(0.208 0.042 265.755); - --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-border: oklch(0.937 0 0); --sidebar-ring: oklch(0.704 0.04 256.788); } diff --git a/client/web/components/calendar/day-column-overlay.tsx b/client/web/components/calendar/day-column-overlay.tsx index d0c1bd6..30453ec 100644 --- a/client/web/components/calendar/day-column-overlay.tsx +++ b/client/web/components/calendar/day-column-overlay.tsx @@ -6,10 +6,12 @@ import type { ScheduledShift } from "@/types/schedule"; import type { Employee } from "@/types/employee"; import type { Role } from "@/types/role"; import { useRestaurant } from "@/contexts/restaurant-context"; +import { useShiftTemplateContext } from "@/contexts/shift-template-context"; import { useShiftCreationFlow } from "@/hooks/use-shift-creation-flow"; import { useEmployeeRoles } from "@/hooks/use-employee-roles"; import { RoleSelectorDialog } from "@/components/shifts/role-selector-dialog"; import { ShiftTemplateOverlay } from "./shift-template-overlay"; +import { ShiftTemplateFormDialog } from "@/components/schedules/shift-template-form-dialog"; import { filterTemplatesForDay, assignColumnsToTemplates, @@ -64,9 +66,13 @@ export function DayColumnOverlay({ rolesLoading, }: DayColumnOverlayProps) { const { selectedRestaurantId } = useRestaurant(); + const { refetch: refetchTemplates } = useShiftTemplateContext(); // Track which role was clicked for filtering employees const [selectedRoleId, setSelectedRoleId] = useState(null); + + // Track which template is being edited + const [editingTemplateId, setEditingTemplateId] = useState(null); // Filter templates for this specific day const templatesForDay = useMemo( @@ -156,6 +162,10 @@ export function DayColumnOverlay({ }); }; + const handleTemplateClick = (template: ShiftTemplate) => { + setEditingTemplateId(template.id); + }; + if (columnAssignments.length === 0) { return null; } @@ -182,7 +192,7 @@ export function DayColumnOverlay({ position={styles} assignedShifts={assignedShifts} isHovered={hoveredTemplateId === assignment.template.id} - onTemplateClick={startShiftCreation} + onTemplateClick={handleTemplateClick} onTemplateHover={onTemplateHover} isPopoverOpen={isPopoverOpen} onPopoverOpenChange={(open) => { @@ -215,6 +225,19 @@ export function DayColumnOverlay({ onSelectRole={handleRoleSelected} /> )} + + {/* Edit Shift Template Dialog */} + !open && setEditingTemplateId(null)} + restaurantId={selectedRestaurantId} + shiftTemplateId={editingTemplateId || undefined} + mode="edit" + onSuccess={() => { + setEditingTemplateId(null); + refetchTemplates(); + }} + /> ); } diff --git a/client/web/components/employees/employee-detail-sheet.tsx b/client/web/components/employees/employee-detail-sheet.tsx index 8142637..0121dba 100644 --- a/client/web/components/employees/employee-detail-sheet.tsx +++ b/client/web/components/employees/employee-detail-sheet.tsx @@ -26,6 +26,7 @@ import type { Employee } from "@/types/employee" import type { Role } from "@/types/role" import { getApiBase } from "@/lib/api" import { fetchWithAuth } from "@/lib/auth" +import { showSuccessToast, showErrorToast } from "@/lib/utils/toast-helpers" interface EmployeeDetailSheetProps { employee: Employee | null @@ -138,7 +139,9 @@ export function EmployeeDetailSheet({ setEmployeeRoles(selectedRoles) } catch (err) { - setRolesError(err instanceof Error ? err.message : "Failed to update roles") + const msg = err instanceof Error ? err.message : "Failed to update roles" + setRolesError(msg) + showErrorToast(msg) setIsAssigningRoles(false) return // Don't exit edit mode if role assignment fails } finally { @@ -147,6 +150,7 @@ export function EmployeeDetailSheet({ } setIsEditing(false) + showSuccessToast("Employee updated successfully") if (onSuccess) { onSuccess() } @@ -160,6 +164,7 @@ export function EmployeeDetailSheet({ setShowDeleteConfirm(false) onOpenChange(false) setIsEditing(false) + showSuccessToast("Employee deleted successfully") if (onSuccess) { onSuccess() } @@ -284,6 +289,8 @@ export function EmployeeDetailSheet({ }) } + showSuccessToast("Role created successfully") + // Trigger parent callback to refresh sidebar role list if (onRoleCreated) { await onRoleCreated() diff --git a/client/web/components/employees/employee-form-dialog.tsx b/client/web/components/employees/employee-form-dialog.tsx index 8b11a84..76866d0 100644 --- a/client/web/components/employees/employee-form-dialog.tsx +++ b/client/web/components/employees/employee-form-dialog.tsx @@ -32,6 +32,7 @@ import { RoleFormDialog } from "@/components/roles/role-form-dialog" import type { Role } from "@/types/role" import { getApiBase } from "@/lib/api" import { fetchWithAuth } from "@/lib/auth" +import { showSuccessToast, showErrorToast } from "@/lib/utils/toast-helpers" interface EmployeeFormDialogProps { mode?: "create" | "edit" @@ -284,6 +285,7 @@ export function EmployeeFormDialog({ // Close dialog and trigger success callback setDialogOpen(false) + showSuccessToast("Employee updated successfully") if (onSuccess) { onSuccess({ id: employeeId }) } @@ -332,6 +334,7 @@ export function EmployeeFormDialog({ // Close dialog and trigger success callback setDialogOpen(false) + showSuccessToast("Employee created successfully") if (onSuccess) { onSuccess(employeeData) } @@ -342,6 +345,7 @@ export function EmployeeFormDialog({ setFormError(errorMessage) setRolesError(errorMessage) setIsAssigningRoles(false) + showErrorToast(errorMessage) } } @@ -351,6 +355,7 @@ export function EmployeeFormDialog({ setShowDeleteConfirm(false) setDialogOpen(false) reset() + showSuccessToast("Employee deleted successfully") if (onSuccess) { onSuccess(null) } @@ -488,6 +493,8 @@ export function EmployeeFormDialog({ return [...prev, roleObj] }) } + + showSuccessToast("Role created successfully") // Trigger parent callback to refresh sidebar role list if (onRoleCreated) { diff --git a/client/web/components/layout/sidebar-left.tsx b/client/web/components/layout/sidebar-left.tsx index 10625d6..beab621 100644 --- a/client/web/components/layout/sidebar-left.tsx +++ b/client/web/components/layout/sidebar-left.tsx @@ -19,7 +19,7 @@ import { FlowButton } from "./flow-button"; const TEMP_USER_DATA = { name: "shadcn", email: "m@example.com", - avatar: "/avatars/shadcn.jpg", + avatar: "/public/logo.png", }; /** diff --git a/client/web/components/roles/role-detail-sheet.tsx b/client/web/components/roles/role-detail-sheet.tsx index 522a4af..05735d1 100644 --- a/client/web/components/roles/role-detail-sheet.tsx +++ b/client/web/components/roles/role-detail-sheet.tsx @@ -14,6 +14,7 @@ import { useRoleForm } from "@/hooks/use-role-form" import { useRoleDelete } from "@/hooks/use-role-delete" import { RoleDeleteDialog } from "./role-delete-dialog" import type { Role } from "@/types/role" +import { showSuccessToast } from "@/lib/utils/toast-helpers" interface RoleDetailSheetProps { role: Role | null @@ -60,6 +61,7 @@ export function RoleDetailSheet({ roleId: role?.id, onSuccess: (updatedRole) => { setIsEditing(false) + showSuccessToast("Role updated successfully") if (onSuccess) { onSuccess() } @@ -73,6 +75,7 @@ export function RoleDetailSheet({ setShowDeleteConfirm(false) onOpenChange(false) setIsEditing(false) + showSuccessToast("Role deleted successfully") if (onSuccess) { onSuccess() } diff --git a/client/web/components/roles/role-form-dialog.tsx b/client/web/components/roles/role-form-dialog.tsx index 66cbc2a..046cc9d 100644 --- a/client/web/components/roles/role-form-dialog.tsx +++ b/client/web/components/roles/role-form-dialog.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useState, useEffect } from "react" import { Button } from "@/components/ui/button" import { Dialog, @@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input" import { useRoleForm } from "@/hooks/use-role-form" import { useRoleDelete } from "@/hooks/use-role-delete" import { RoleDeleteDialog } from "./role-delete-dialog" +import { showSuccessToast, showErrorToast } from "@/lib/utils/toast-helpers" interface RoleFormDialogProps { mode?: "create" | "edit" @@ -57,6 +58,7 @@ export function RoleFormDialog({ const handleSuccess = (role: unknown) => { setDialogOpen(false) + showSuccessToast(mode === "edit" ? "Role updated successfully" : "Role created successfully") if (onSuccess) { onSuccess(role) } @@ -79,12 +81,20 @@ export function RoleFormDialog({ isOpen: dialogOpen, }) + // Show error toast if useRoleForm returns an error + useEffect(() => { + if (error) { + showErrorToast(error) + } + }, [error]) + const { isDeleting, deleteRole } = useRoleDelete({ restaurantId, onSuccess: () => { setShowDeleteConfirm(false) setDialogOpen(false) reset() + showSuccessToast("Role deleted successfully") if (onSuccess) { onSuccess(null) } diff --git a/client/web/components/schedules/shift-template-form-dialog.tsx b/client/web/components/schedules/shift-template-form-dialog.tsx index a646cf0..7829878 100644 --- a/client/web/components/schedules/shift-template-form-dialog.tsx +++ b/client/web/components/schedules/shift-template-form-dialog.tsx @@ -31,6 +31,7 @@ import { getApiBase } from "@/lib/api"; import { fetchWithAuth } from "@/lib/auth"; import type { Role } from "@/types/role"; import { RoleFormDialog } from "@/components/roles/role-form-dialog"; +import { showSuccessToast, showErrorToast } from "@/lib/utils/toast-helpers"; interface ShiftTemplateFormDialogProps { mode?: "create" | "edit"; @@ -98,10 +99,25 @@ export function ShiftTemplateFormDialog({ } }; - const handleSuccess = (shiftTemplate: unknown) => { + const handleSuccess = (result: unknown) => { setDialogOpen(false); + + if (result === null) { + showSuccessToast("Shift template deleted successfully"); + } else if (Array.isArray(result)) { + showSuccessToast( + `Successfully created ${result.length} shift template(s)` + ); + } else { + showSuccessToast( + mode === "edit" + ? "Shift template updated successfully" + : "Shift template created successfully" + ); + } + if (onSuccess) { - onSuccess(shiftTemplate); + onSuccess(result); } }; @@ -109,6 +125,7 @@ export function ShiftTemplateFormDialog({ register, handleSubmit, onSubmit, + deleteShiftTemplate, errors, isSubmitting, isLoading, @@ -124,6 +141,13 @@ export function ShiftTemplateFormDialog({ isOpen: dialogOpen, }); + // Effect to show hook errors as toasts + useEffect(() => { + if (error) { + showErrorToast(error); + } + }, [error]); + const isEditMode = mode === "edit"; const dialogTitle = isEditMode ? "Edit Shift Template" @@ -152,9 +176,15 @@ export function ShiftTemplateFormDialog({ const [selectedRoles, setSelectedRoles] = useState([]); const [rolesLoading, setRolesLoading] = useState(false); const [rolesError, setRolesError] = useState(null); - const [rolesValidationError, setRolesValidationError] = useState(null); + const [rolesValidationError, setRolesValidationError] = useState< + string | null + >(null); const [showRoleDialog, setShowRoleDialog] = useState(false); + // State to hold fetched template data for synchronization + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [fetchedTemplate, setFetchedTemplate] = useState(null); + // Submit button text (defined after state to access isCreating and selectedDays) const submitButtonText = isCreating ? `Creating ${selectedDays.length} template${ @@ -232,9 +262,9 @@ export function ShiftTemplateFormDialog({ setAvailableRoles(roles); } catch (err) { console.error("Error fetching roles:", err); - setRolesError( - err instanceof Error ? err.message : "Failed to load roles" - ); + const msg = err instanceof Error ? err.message : "Failed to load roles"; + setRolesError(msg); + showErrorToast(msg); setAvailableRoles([]); } finally { setRolesLoading(false); @@ -248,17 +278,64 @@ export function ShiftTemplateFormDialog({ } }, [dialogOpen, restaurantId, fetchRoles]); + // Fetch shift template details in edit mode + useEffect(() => { + if (dialogOpen && isEditMode && restaurantId && shiftTemplateId) { + const fetchTemplate = async () => { + try { + const res = await fetchWithAuth( + `${getApiBase()}/restaurants/${restaurantId}/shift-templates/${shiftTemplateId}` + ); + if (!res.ok) throw new Error("Failed to fetch shift template"); + const responseJson = await res.json(); + const data = responseJson.data || responseJson; + setFetchedTemplate(data); + + if (data) { + // Populate simple form fields immediately + setValue("name", data.name); + setValue("start_time", data.start_time?.slice(0, 5)); // Ensure HH:MM + setValue("end_time", data.end_time?.slice(0, 5)); + setSelectedDays([data.day_of_week]); + setValue("day_of_week", data.day_of_week); + } + } catch (err) { + console.error("Error fetching template:", err); + const msg = "Failed to load shift template details"; + setSubmissionError(msg); + showErrorToast(msg); + } + }; + fetchTemplate(); + } + }, [dialogOpen, isEditMode, restaurantId, shiftTemplateId, setValue]); + + // Sync selectedRoles with fetchedTemplate.role_ids once roles are loaded + useEffect(() => { + if (fetchedTemplate && availableRoles.length > 0) { + const roleIds = fetchedTemplate.role_ids || []; + const roles = availableRoles.filter((r) => roleIds.includes(r.id)); + setSelectedRoles(roles); + // Also update form value for role_ids + setValue("role_ids", roleIds); + } + }, [fetchedTemplate, availableRoles, setValue]); + // Clear validation errors when dialog opens/closes useEffect(() => { if (!dialogOpen) { setRolesValidationError(null); setSubmissionError(null); + setFetchedTemplate(null); + setSelectedRoles([]); + setSelectedDays([]); + reset(); // Reset form } - }, [dialogOpen]); + }, [dialogOpen, reset]); - // Apply initial values when dialog opens + // Apply initial values when dialog opens (Create Mode only) useEffect(() => { - if (dialogOpen) { + if (dialogOpen && !isEditMode) { // Apply initial day of week if (initialDayOfWeek !== undefined) { setSelectedDays([initialDayOfWeek]); @@ -280,11 +357,23 @@ export function ShiftTemplateFormDialog({ setValue("end_time", initialEndTime); } } - }, [dialogOpen, initialDayOfWeek, initialStartTime, initialEndTime, setValue]); + }, [ + dialogOpen, + isEditMode, + initialDayOfWeek, + initialStartTime, + initialEndTime, + setValue, + ]); // Handler for role removal const handleRoleRemove = (roleId: number) => { - setSelectedRoles(selectedRoles.filter((r) => r.id !== roleId)); + const newRoles = selectedRoles.filter((r) => r.id !== roleId); + setSelectedRoles(newRoles); + setValue( + "role_ids", + newRoles.map((r) => r.id) + ); }; // Handler for role creation @@ -293,41 +382,70 @@ export function ShiftTemplateFormDialog({ await fetchRoles(); // Extract role from either direct or wrapped response - const role = (newRole && typeof newRole === 'object' && 'data' in newRole) - ? (newRole as Record).data - : newRole; + 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) { + if (role && typeof role === "object" && "id" in role) { const roleObj = role as Role; - setSelectedRoles(prev => { + setSelectedRoles((prev) => { // Only add if not already selected - if (prev.some(r => r.id === roleObj.id)) { + if (prev.some((r) => r.id === roleObj.id)) { return prev; } - return [...prev, roleObj]; + const newRoles = [...prev, roleObj]; + setValue( + "role_ids", + newRoles.map((r) => r.id) + ); + return newRoles; }); + showSuccessToast("Role created successfully"); } setShowRoleDialog(false); }; - // Custom submit handler for multi-day creation - const handleMultiDaySubmit = async (e: React.FormEvent) => { + // Custom submit handler for multi-day creation or single edit + const handleFormSubmit = async (e: React.FormEvent) => { e.preventDefault(); - // Validate that at least one day is selected - if (selectedDays.length === 0) { - setSubmissionError("Please select at least one day"); - return; - } - // Validate that at least one role is selected if (selectedRoles.length === 0) { setRolesValidationError("Please select at least one role"); return; } + if (isEditMode) { + // Edit Mode Logic + if (selectedDays.length !== 1) { + setSubmissionError( + "Please select exactly one day for the shift template." + ); + return; + } + + // Ensure form values are synced + setValue( + "role_ids", + selectedRoles.map((r) => r.id) + ); + setValue("day_of_week", selectedDays[0]); + + // Use the hook's onSubmit + await handleSubmit(onSubmit)(e); + return; + } + + // Create Mode Logic (Multi-day) + // Validate that at least one day is selected + if (selectedDays.length === 0) { + setSubmissionError("Please select at least one day"); + return; + } + setIsCreating(true); setSubmissionError(null); setRolesValidationError(null); @@ -400,21 +518,22 @@ export function ShiftTemplateFormDialog({ setSubmissionError( `${errorMsg}\n\nSuccessfully created ${createdTemplates.length} template(s).` ); + showErrorToast(errorMsg); } else { setSubmissionError(errorMsg); + showErrorToast(errorMsg); } } else { // All succeeded - close dialog and notify parent - setDialogOpen(false); + // handleSuccess will show the toast + handleSuccess(createdTemplates); setSelectedDays([]); - if (onSuccess) { - onSuccess(createdTemplates); - } } } catch (err) { - setSubmissionError( - err instanceof Error ? err.message : "Failed to create shift templates" - ); + const msg = + err instanceof Error ? err.message : "Failed to create shift templates"; + setSubmissionError(msg); + showErrorToast(msg); } finally { setIsCreating(false); } @@ -428,291 +547,358 @@ export function ShiftTemplateFormDialog({ {dialogTitle} {dialogDescription} -
- {(submissionError || error || isLoading) && ( -
- {submissionError || error || "Loading shift template details..."} -
- )} - - - {/* Name Field */} - - Name - - {errors.name && ( -

{errors.name.message}

- )} -
- - {/* Days of Week Selection (Multi-select with Popover) */} - - Days of Week - - - + + - {selectedDays.length > 0 - ? `${selectedDays.length} day${selectedDays.length > 1 ? "s" : ""} selected` - : "Select days"} - - - - -
- {DAYS_OF_WEEK.map((day) => ( - - ))} +
+ {DAYS_OF_WEEK.map((day) => ( + + ))} +
+ + + + {/* Display selected days as badges */} + {selectedDays.length > 0 && ( +
+ {selectedDays.map((dayValue) => { + const day = DAYS_OF_WEEK.find( + (d) => d.value === dayValue + ); + return ( + + {day?.label} + + + ); + })}
- - - - {/* Display selected days as badges */} - {selectedDays.length > 0 && ( -
- {selectedDays.map((dayValue) => { - const day = DAYS_OF_WEEK.find((d) => d.value === dayValue); - return ( + )} + + {errors.day_of_week && ( +

+ {errors.day_of_week.message} +

+ )} + + + {/* Roles Selection (Multi-select with Popover) */} + + Roles + + + + + +
+ {/* Checkbox for each role */} + {availableRoles.map((role) => ( + + ))} + + {/* Divider */} + {availableRoles.length > 0 && ( +
+ )} + + {/* Create new role button */} + +
+ + + + {/* Error messages */} + {rolesError && ( +

{rolesError}

+ )} + + {rolesValidationError && ( +

{rolesValidationError}

+ )} + + {/* Display selected roles as badges */} + {selectedRoles.length > 0 && ( +
+ {selectedRoles.map((role) => ( - {day?.label} + {role.name} - ); - })} -
- )} - - {errors.day_of_week && ( -

- {errors.day_of_week.message} -

- )} - - - {/* Roles Selection (Multi-select with Popover) */} - - Roles - - - - - -
- {/* Checkbox for each role */} - {availableRoles.map((role) => ( - ))} - - {/* Divider */} - {availableRoles.length > 0 && ( -
- )} - - {/* Create new role button */} -
- - - - {/* Error messages */} - {rolesError && ( -

{rolesError}

- )} - - {rolesValidationError && ( -

{rolesValidationError}

- )} - - {/* Display selected roles as badges */} - {selectedRoles.length > 0 && ( -
- {selectedRoles.map((role) => ( - - {role.name} - - - ))} + )} + + + {/* Start Time */} + + Start Time +
+ +
- )} -
- - {/* Start Time */} - - Start Time -
- - + + + + + {HOURS.map((hour) => ( + + {hour.label} + + ))} + + + +
+ {errors.end_time && ( +

+ {errors.end_time.message} +

+ )} +
+ + +
+ {/* Delete Button (Only in Edit Mode) */} + {isEditMode ? ( +
- {errors.start_time && ( -

- {errors.start_time.message} -

+ Delete + + ) : ( +
/* Spacer */ )} - - - {/* End Time */} - - End Time -
- - -
- {errors.end_time && ( -

- {errors.end_time.message} -

- )} -
- - -
- -
- - - - - {/* Role Creation Dialog */} - - + + +
+ + + + + {/* Role Creation Dialog */} + + ); } diff --git a/client/web/components/workspaces/workspace-form-dialog.tsx b/client/web/components/workspaces/workspace-form-dialog.tsx index e525a8b..159fd49 100644 --- a/client/web/components/workspaces/workspace-form-dialog.tsx +++ b/client/web/components/workspaces/workspace-form-dialog.tsx @@ -23,6 +23,7 @@ import { useWorkspaceDelete } from "@/hooks/use-workspace-delete" import { WorkspaceDeleteDialog } from "./workspace-delete-dialog" import { PlacesAutocompleteInput } from "./places-autocomplete-input" import type { Workspace } from "@/types/workspace" +import { showSuccessToast } from "@/lib/utils/toast-helpers" interface WorkspaceFormDialogProps { mode?: "create" | "edit" @@ -57,6 +58,7 @@ export function WorkspaceFormDialog({ const handleSuccess = (workspace: Workspace | null) => { setDialogOpen(false) + showSuccessToast(mode === "edit" ? "Workplace updated successfully" : "Workplace created successfully") if (onSuccess) { onSuccess(workspace) } @@ -86,6 +88,7 @@ export function WorkspaceFormDialog({ setShowDeleteConfirm(false) setDialogOpen(false) reset() + showSuccessToast("Workplace deleted successfully") if (onSuccess) { onSuccess(null) } diff --git a/client/web/hooks/use-employee-delete.tsx b/client/web/hooks/use-employee-delete.tsx index 7193898..8f24753 100644 --- a/client/web/hooks/use-employee-delete.tsx +++ b/client/web/hooks/use-employee-delete.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { getApiBase } from "@/lib/api" import { fetchWithAuth } from "@/lib/auth" +import { showErrorToast } from "@/lib/utils/toast-helpers" export interface UseEmployeeDeleteOptions { restaurantId: number | null @@ -52,7 +53,9 @@ export function useEmployeeDelete({ restaurantId, onSuccess }: UseEmployeeDelete onSuccess() } } catch (err) { - setError(err instanceof Error ? err.message : "Failed to delete employee. Please try again.") + const msg = err instanceof Error ? err.message : "Failed to delete employee. Please try again." + setError(msg) + showErrorToast(msg) throw err } finally { setIsDeleting(false) diff --git a/client/web/hooks/use-role-delete.tsx b/client/web/hooks/use-role-delete.tsx index 8bf9cd8..b6339fc 100644 --- a/client/web/hooks/use-role-delete.tsx +++ b/client/web/hooks/use-role-delete.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { getApiBase } from "@/lib/api" import { fetchWithAuth } from "@/lib/auth" +import { showErrorToast } from "@/lib/utils/toast-helpers" export interface UseRoleDeleteOptions { restaurantId: number | null @@ -67,6 +68,7 @@ export function useRoleDelete({ } catch (err) { const errorMessage = err instanceof Error ? err.message : "Failed to delete role" setError(errorMessage) + showErrorToast(errorMessage) throw err } finally { setIsDeleting(false) diff --git a/client/web/hooks/use-shift-creation-flow.tsx b/client/web/hooks/use-shift-creation-flow.tsx index 6dd7a2c..fd15599 100644 --- a/client/web/hooks/use-shift-creation-flow.tsx +++ b/client/web/hooks/use-shift-creation-flow.tsx @@ -151,7 +151,7 @@ export function useShiftCreationFlow({ confirmOptimisticShift(tempId, realShift); } - showSuccessToast("Shift created successfully"); + showSuccessToast("Employee assigned successfully"); } catch (error) { console.error("Failed to create shift:", error); diff --git a/client/web/hooks/use-shift-template-form.tsx b/client/web/hooks/use-shift-template-form.tsx index 5c6bd75..e6620a6 100644 --- a/client/web/hooks/use-shift-template-form.tsx +++ b/client/web/hooks/use-shift-template-form.tsx @@ -28,6 +28,7 @@ const shiftTemplateSchema = z end_time: z .string() .regex(timeRegex, "End time must be in HH:MM format (e.g., 17:00)"), + role_ids: z.array(z.number()).optional(), }) .refine( (data) => { @@ -71,6 +72,7 @@ export interface ShiftTemplateFormData { day_of_week: number; start_time: string; end_time: string; + role_ids?: number[]; } export interface UseShiftTemplateFormOptions { @@ -110,6 +112,7 @@ export function useShiftTemplateForm({ day_of_week: 0, start_time: "09:00", end_time: "17:00", + role_ids: [], }, }); @@ -130,6 +133,7 @@ export function useShiftTemplateForm({ day_of_week: data.day_of_week, start_time: data.start_time, end_time: data.end_time, + role_ids: data.role_ids, }; const isEdit = mode === "edit"; @@ -173,10 +177,39 @@ export function useShiftTemplateForm({ } }; + const deleteShiftTemplate = async () => { + if (!restaurantId || !shiftTemplateId) return; + + setIsLoading(true); + setError(null); + + try { + const res = await fetchWithAuth( + `${getApiBase()}/restaurants/${restaurantId}/shift-templates/${shiftTemplateId}`, + { + method: "DELETE", + } + ); + + if (!res.ok) { + throw new Error("Failed to delete shift template"); + } + + if (onSuccess) { + onSuccess(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete template"); + } finally { + setIsLoading(false); + } + }; + return { register, handleSubmit, onSubmit, + deleteShiftTemplate, errors, isSubmitting, isLoading, diff --git a/client/web/hooks/use-workspace-delete.tsx b/client/web/hooks/use-workspace-delete.tsx index 82621a5..0608d9a 100644 --- a/client/web/hooks/use-workspace-delete.tsx +++ b/client/web/hooks/use-workspace-delete.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { getApiBase } from "@/lib/api" import { fetchWithAuth } from "@/lib/auth" +import { showErrorToast } from "@/lib/utils/toast-helpers" export interface UseWorkspaceDeleteOptions { onSuccess?: () => void @@ -46,7 +47,9 @@ export function useWorkspaceDelete({ onSuccess }: UseWorkspaceDeleteOptions = {} onSuccess() } } catch (err: any) { - setError(err?.message || "Failed to delete workspace. Please try again.") + const msg = err?.message || "Failed to delete workspace. Please try again." + setError(msg) + showErrorToast(msg) throw err } finally { setIsDeleting(false) diff --git a/client/web/hooks/use-workspace-form.tsx b/client/web/hooks/use-workspace-form.tsx index f14ea8e..2e0898e 100644 --- a/client/web/hooks/use-workspace-form.tsx +++ b/client/web/hooks/use-workspace-form.tsx @@ -7,6 +7,7 @@ import * as z from "zod" import { getApiBase } from "@/lib/api" import { fetchWithAuth } from "@/lib/auth" import type { WorkspaceFormData } from "@/types/workspace" +import { showErrorToast } from "@/lib/utils/toast-helpers" const workspaceSchema = z.object({ name: z.string().min(1, "Workplace name is required").max(255, "Name must be less than 255 characters"), @@ -130,7 +131,9 @@ export function useWorkspaceForm({ onSuccess(workspace) } } catch (err: any) { - setError(err?.message || "Something went wrong. Please try again.") + const msg = err?.message || "Something went wrong. Please try again." + setError(msg) + showErrorToast(msg) } } From 5be6adde90785bc34e6930ec6d571cfcd27b6651 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sat, 6 Dec 2025 20:46:58 -0800 Subject: [PATCH 3/5] feat: Use auth context for dynamic user data in sidebar --- client/web/components/layout/sidebar-left.tsx | 20 +++--- client/web/components/layout/user-avatar.tsx | 23 +++---- client/web/components/layout/user-menu.tsx | 24 +++---- client/web/lib/auth.tsx | 1 + cmd/api/auth.go | 69 +++++++++++-------- 5 files changed, 74 insertions(+), 63 deletions(-) diff --git a/client/web/components/layout/sidebar-left.tsx b/client/web/components/layout/sidebar-left.tsx index beab621..326bbcf 100644 --- a/client/web/components/layout/sidebar-left.tsx +++ b/client/web/components/layout/sidebar-left.tsx @@ -8,19 +8,12 @@ import { SidebarFooter, SidebarRail, } from "@/components/ui/sidebar"; -import { Button } from "@/components/ui/button"; import { UserMenu } from "@/components/layout/user-menu"; import { WorkspaceList } from "@/components/workspaces/workspace-list"; import { RoleLegend } from "@/components/roles/role-legend"; import { useWorkplaces } from "@/hooks/use-workplaces"; import { FlowButton } from "./flow-button"; - -// TODO: Replace with actual user data from auth context -const TEMP_USER_DATA = { - name: "shadcn", - email: "m@example.com", - avatar: "/public/logo.png", -}; +import { useAuth } from "@/lib/auth"; /** * Left sidebar component - Simplified and refactored @@ -32,11 +25,20 @@ export function SidebarLeft({ ...props }: React.ComponentProps) { const { workplaces, refetch } = useWorkplaces(); + const { user } = useAuth(); + + const userData = { + name: user + ? `${user.first_name || ""} ${user.last_name || ""}`.trim() + : "Guest", + email: user?.email || "", + avatar: user?.avatar_url || "", + }; return ( - + diff --git a/client/web/components/layout/user-avatar.tsx b/client/web/components/layout/user-avatar.tsx index 9e2e570..06dd47c 100644 --- a/client/web/components/layout/user-avatar.tsx +++ b/client/web/components/layout/user-avatar.tsx @@ -1,20 +1,19 @@ -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@/components/ui/avatar" -import type { User } from "@/types/user" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import type { User } from "@/types/user"; interface UserAvatarProps { - user: User - className?: string + user: User; + className?: string; } /** * Reusable user avatar component * Extracted from nav-user for better reusability */ -export function UserAvatar({ user, className = "h-8 w-8 rounded-lg" }: UserAvatarProps) { +export function UserAvatar({ + user, + className = "h-6 w-6 rounded-full mr-1", +}: UserAvatarProps) { // Generate initials from user name const getInitials = (name: string) => { return name @@ -22,8 +21,8 @@ export function UserAvatar({ user, className = "h-8 w-8 rounded-lg" }: UserAvata .map((part) => part[0]) .join("") .toUpperCase() - .slice(0, 2) - } + .slice(0, 2); + }; return ( @@ -32,5 +31,5 @@ export function UserAvatar({ user, className = "h-8 w-8 rounded-lg" }: UserAvata {getInitials(user.name)} - ) + ); } diff --git a/client/web/components/layout/user-menu.tsx b/client/web/components/layout/user-menu.tsx index deb674d..b92ee6c 100644 --- a/client/web/components/layout/user-menu.tsx +++ b/client/web/components/layout/user-menu.tsx @@ -1,30 +1,28 @@ -"use client" +"use client"; -import { - ChevronsUpDown, -} from "lucide-react" +import { ChevronsUpDown } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" +} from "@/components/ui/dropdown-menu"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, -} from "@/components/ui/sidebar" -import { UserAvatar } from "@/components/layout/user-avatar" -import { UserDropdownItems } from "@/components/layout/user-dropdown-items" -import type { UserMenuProps } from "@/types/user" +} from "@/components/ui/sidebar"; +import { UserAvatar } from "@/components/layout/user-avatar"; +import { UserDropdownItems } from "@/components/layout/user-dropdown-items"; +import type { UserMenuProps } from "@/types/user"; /** * User menu component with avatar and dropdown * Refactored from nav-user with extracted sub-components */ export function UserMenu({ user }: UserMenuProps) { - const { isMobile } = useSidebar() + const { isMobile } = useSidebar(); return ( @@ -36,9 +34,9 @@ export function UserMenu({ user }: UserMenuProps) { className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground hover:cursor-pointer" > -
+
{user.name} - {user.email} + {user.email}
@@ -63,5 +61,5 @@ export function UserMenu({ user }: UserMenuProps) { - ) + ); } diff --git a/client/web/lib/auth.tsx b/client/web/lib/auth.tsx index 2501d0f..8c8f130 100644 --- a/client/web/lib/auth.tsx +++ b/client/web/lib/auth.tsx @@ -30,6 +30,7 @@ export interface User { email?: string first_name?: string last_name?: string + avatar_url?: string } interface AuthContextType { diff --git a/cmd/api/auth.go b/cmd/api/auth.go index e1f766c..0b42331 100644 --- a/cmd/api/auth.go +++ b/cmd/api/auth.go @@ -248,18 +248,8 @@ func (app *application) createTokenHandler(w http.ResponseWriter, r *http.Reques return } - // generate the token -> add claims - claims := jwt.MapClaims{ - "sub": user.ID, - "exp": time.Now().Add(app.config.auth.token.exp).Unix(), - "iat": time.Now().Unix(), - "nbf": time.Now().Unix(), - "iss": app.config.auth.token.iss, - "aud": app.config.auth.token.iss, - } - - // TODO:: set a cookie for the frontend to consume - token, err := app.authenticator.GenerateToken(claims) + // Generate token using helper that includes all user details + token, err := app.generateTokenForUser(user) if err != nil { app.internalServerError(w, r, err) return @@ -319,25 +309,39 @@ func (app *application) refreshTokenHandler(w http.ResponseWriter, r *http.Reque } // Get user ID from claims - userID, ok := claims["sub"] + subClaim, ok := claims["sub"] if !ok { app.logger.Errorw("missing subject claim in token") app.internalServerError(w, r, fmt.Errorf("missing subject claim")) return } - // Create new token with fresh expiry - newClaims := jwt.MapClaims{ - "sub": userID, - "exp": time.Now().Add(app.config.auth.token.exp).Unix(), - "iat": time.Now().Unix(), - "nbf": time.Now().Unix(), - "iss": app.config.auth.token.iss, - "aud": app.config.auth.token.iss, + // Handle float64 (default for JSON numbers) + var userID int64 + switch v := subClaim.(type) { + case float64: + userID = int64(v) + case int64: + userID = v + default: + app.logger.Errorw("invalid subject claim type", "type", fmt.Sprintf("%T", subClaim)) + app.internalServerError(w, r, fmt.Errorf("invalid subject claim type")) + return + } + + // Fetch the user to ensure they still exist and get fresh details + user, err := app.store.Users.GetByID(r.Context(), userID) + if err != nil { + if err == store.ErrNotFound { + app.unauthorizedErrorResponse(w, r, fmt.Errorf("user no longer exists")) + return + } + app.internalServerError(w, r, err) + return } - // Generate new token - newToken, err := app.authenticator.GenerateToken(newClaims) + // Generate new token with updated user info + newToken, err := app.generateTokenForUser(user) if err != nil { app.logger.Errorw("failed to generate new token", "error", err) app.internalServerError(w, r, err) @@ -522,12 +526,19 @@ func (app *application) googleCallbackHandler(w http.ResponseWriter, r *http.Req // generateTokenForUser is a helper function to generate JWT token for a user func (app *application) generateTokenForUser(user *store.User) (string, error) { claims := jwt.MapClaims{ - "sub": user.ID, - "exp": time.Now().Add(app.config.auth.token.exp).Unix(), - "iat": time.Now().Unix(), - "nbf": time.Now().Unix(), - "iss": app.config.auth.token.iss, - "aud": app.config.auth.token.iss, + "sub": user.ID, + "exp": time.Now().Add(app.config.auth.token.exp).Unix(), + "iat": time.Now().Unix(), + "nbf": time.Now().Unix(), + "iss": app.config.auth.token.iss, + "aud": app.config.auth.token.iss, + "email": user.Email, + "first_name": user.FirstName, + "last_name": user.LastName, + } + + if user.AvatarURL != nil { + claims["avatar_url"] = *user.AvatarURL } return app.authenticator.GenerateToken(claims) From 62008697eee993c458accbeb62c1af9cbd8d7ac7 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sat, 6 Dec 2025 22:08:16 -0800 Subject: [PATCH 4/5] add: a lot of UI changes lol --- client/web/app/(resa)/layout.tsx | 83 +++++++++++++++---- .../employee-collapsible-section.tsx | 67 ++++++++------- .../web/components/layout/sidebar-right.tsx | 6 +- .../roles/role-collapsible-section.tsx | 77 ++++++++--------- client/web/components/ui/sidebar.tsx | 6 +- 5 files changed, 147 insertions(+), 92 deletions(-) diff --git a/client/web/app/(resa)/layout.tsx b/client/web/app/(resa)/layout.tsx index 03a3c68..e6fc25e 100644 --- a/client/web/app/(resa)/layout.tsx +++ b/client/web/app/(resa)/layout.tsx @@ -13,11 +13,17 @@ import { SidebarInset, SidebarProvider, SidebarTrigger, + useSidebar, } from "@/components/ui/sidebar"; import { Button } from "@/components/ui/button"; -import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react"; +import { + ChevronLeft, + ChevronRight, + ChevronDown, + PanelRight, +} from "lucide-react"; -import { useEffect } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth"; import { RestaurantProvider } from "@/contexts/restaurant-context"; @@ -38,6 +44,7 @@ export default function ResaLayout({ }) { const { isAuthenticated, isLoading } = useAuth(); const router = useRouter(); + const [rightSidebarOpen, setRightSidebarOpen] = useState(true); useEffect(() => { // Wait for auth state to load @@ -69,10 +76,19 @@ export default function ResaLayout({ - +
{children}
- + + +
@@ -84,8 +100,26 @@ export default function ResaLayout({ * Header component that conditionally shows week navigation * when WeekNavigationProvider is available (on schedule pages) */ -function ResaHeader() { +function ResaHeader({ + rightSidebarOpen, + setRightSidebarOpen, +}: { + rightSidebarOpen?: boolean; + setRightSidebarOpen?: (open: boolean) => void; +}) { const weekNav = useWeekNavigation(); + const { open: leftSidebarOpen, setOpen: setLeftOpen } = useSidebar(); + + // Close left sidebar on window resize if screen is small + useEffect(() => { + const handleResize = () => { + if (window.innerWidth < 1024 && leftSidebarOpen) { + setLeftOpen(false); + } + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [leftSidebarOpen, setLeftOpen]); // Get current month and year const currentDate = new Date(); @@ -99,7 +133,12 @@ function ResaHeader() {
{/* Left side */}
- + { + // If opening left sidebar on small screen, close right sidebar + if (!leftSidebarOpen && window.innerWidth < 1024 && setRightSidebarOpen) { + setRightSidebarOpen(false); + } + }} /> - - + + {setRightSidebarOpen && ( + + )}
)}
diff --git a/client/web/components/employees/employee-collapsible-section.tsx b/client/web/components/employees/employee-collapsible-section.tsx index c81d77c..6c54944 100644 --- a/client/web/components/employees/employee-collapsible-section.tsx +++ b/client/web/components/employees/employee-collapsible-section.tsx @@ -13,6 +13,7 @@ import { SidebarGroupContent, SidebarGroupLabel, SidebarMenu, + SidebarMenuButton, SidebarMenuItem, SidebarSeparator, } from "@/components/ui/sidebar"; @@ -50,37 +51,37 @@ function EmployeeItem({ employee, roles, onEdit }: EmployeeItemProps) { return ( -
-
- {/* Employee name and email */} -
- {employee.full_name} -
- - {/* Role badges */} - {roles.length > 0 ? ( -
- {roles.map((role) => ( - - {role.name} - - ))} + + {/* */} +
+
+ {/* Name + roles together */} +
+ {employee.full_name} + + {roles.length > 0 ? ( +
+ {roles.map((role) => ( + + {role.name} + + ))} +
+ ) : ( + + No roles + + )}
- ) : ( - No roles - )} +
- - {/* Edit button */} - -
+ ); } @@ -100,7 +101,7 @@ export const EmployeeCollapsibleSection = forwardRef< const [isSheetOpen, setIsSheetOpen] = useState(false); // Fetch employee roles to display as badges - const { employeesWithRoles } = useEmployeeRoles({ + const { employeesWithRoles, refetch: refetchRoles } = useEmployeeRoles({ restaurantId, employees, enabled: true, // Always enabled for sidebar display @@ -108,7 +109,10 @@ export const EmployeeCollapsibleSection = forwardRef< // Expose refetch method to parent component useImperativeHandle(ref, () => ({ - refetch, + refetch: () => { + refetch(); + refetchRoles(); + }, })); // Update selected employee when employees list changes @@ -130,6 +134,7 @@ export const EmployeeCollapsibleSection = forwardRef< const handleEmployeeUpdate = () => { refetch(); + refetchRoles(); }; // Show message when no restaurant is selected diff --git a/client/web/components/layout/sidebar-right.tsx b/client/web/components/layout/sidebar-right.tsx index 48ca1cf..c8694bf 100644 --- a/client/web/components/layout/sidebar-right.tsx +++ b/client/web/components/layout/sidebar-right.tsx @@ -83,11 +83,7 @@ export function SidebarRight({ return ( <> - + diff --git a/client/web/components/roles/role-collapsible-section.tsx b/client/web/components/roles/role-collapsible-section.tsx index a2715b2..09bf40d 100644 --- a/client/web/components/roles/role-collapsible-section.tsx +++ b/client/web/components/roles/role-collapsible-section.tsx @@ -1,13 +1,13 @@ -"use client" +"use client"; -import * as React from "react" -import { useState, useEffect, forwardRef, useImperativeHandle } from "react" -import { ChevronRight, Briefcase } from "lucide-react" +import * as React from "react"; +import { useState, useEffect, forwardRef, useImperativeHandle } from "react"; +import { ChevronRight } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, -} from "@/components/ui/collapsible" +} from "@/components/ui/collapsible"; import { SidebarGroup, SidebarGroupContent, @@ -16,17 +16,17 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarSeparator, -} from "@/components/ui/sidebar" -import { useRoles } from "@/hooks/use-roles" -import { RoleDetailSheet } from "./role-detail-sheet" -import type { Role } from "@/types/role" +} from "@/components/ui/sidebar"; +import { useRoles } from "@/hooks/use-roles"; +import { RoleDetailSheet } from "./role-detail-sheet"; +import type { Role } from "@/types/role"; interface RoleCollapsibleSectionProps { - restaurantId: number | null + restaurantId: number | null; } export interface RoleCollapsibleSectionRef { - refetch: () => void + refetch: () => void; } /** @@ -37,36 +37,34 @@ export interface RoleCollapsibleSectionRef { export const RoleCollapsibleSection = forwardRef< RoleCollapsibleSectionRef, RoleCollapsibleSectionProps ->(function RoleCollapsibleSection({ - restaurantId, -}, ref) { - const { roles, refetch } = useRoles(restaurantId) - const [selectedRole, setSelectedRole] = useState(null) - const [isSheetOpen, setIsSheetOpen] = useState(false) +>(function RoleCollapsibleSection({ restaurantId }, ref) { + const { roles, refetch } = useRoles(restaurantId); + const [selectedRole, setSelectedRole] = useState(null); + const [isSheetOpen, setIsSheetOpen] = useState(false); // Expose refetch method to parent component useImperativeHandle(ref, () => ({ refetch, - })) + })); // Update selected role when roles list changes useEffect(() => { if (selectedRole && roles.length > 0) { - const updatedRole = roles.find(r => r.id === selectedRole.id) + const updatedRole = roles.find((r) => r.id === selectedRole.id); if (updatedRole) { - setSelectedRole(updatedRole) + setSelectedRole(updatedRole); } } - }, [roles, selectedRole?.id]) + }, [roles, selectedRole?.id]); const handleRoleClick = (role: Role) => { - setSelectedRole(role) - setIsSheetOpen(true) - } + setSelectedRole(role); + setIsSheetOpen(true); + }; const handleRoleUpdate = () => { - refetch() - } + refetch(); + }; // Show message when no restaurant is selected if (!restaurantId) { @@ -86,21 +84,21 @@ export const RoleCollapsibleSection = forwardRef< - ) + ); } // Format dates for display const formatDate = (dateString: string) => { try { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }) + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); } catch { - return dateString + return dateString; } - } + }; return ( <> @@ -132,9 +130,12 @@ export const RoleCollapsibleSection = forwardRef< onClick={() => handleRoleClick(role)} className="h-auto min-h-14 py-2 hover:cursor-pointer" > - +
- {role.name} + {role.name} Created {formatDate(role.created_at)} @@ -159,5 +160,5 @@ export const RoleCollapsibleSection = forwardRef< onSuccess={handleRoleUpdate} /> - ) -}) + ); +}); diff --git a/client/web/components/ui/sidebar.tsx b/client/web/components/ui/sidebar.tsx index 3297d9f..028f830 100644 --- a/client/web/components/ui/sidebar.tsx +++ b/client/web/components/ui/sidebar.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; -import { PanelLeftIcon } from "lucide-react"; +import { PanelLeft } from "lucide-react"; import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; @@ -266,14 +266,14 @@ function SidebarTrigger({ data-slot="sidebar-trigger" variant="ghost" size="icon" - className={cn("size-7", className)} + className={cn("size-6", className)} onClick={(event) => { onClick?.(event); toggleSidebar(); }} {...props} > - + Toggle Sidebar ); From 0640b7afd5752694de46443b2dabe2ee10a09169 Mon Sep 17 00:00:00 2001 From: Caleb Bae Date: Sat, 6 Dec 2025 22:26:23 -0800 Subject: [PATCH 5/5] add: notes for future --- .../calendar/day-column-overlay.tsx | 28 +++++++++- .../calendar/shift-template-overlay.tsx | 7 ++- .../web/components/layout/sidebar-right.tsx | 2 +- client/web/hooks/use-schedule-management.tsx | 39 ++++++++++++- client/web/hooks/use-shift-creation-flow.tsx | 56 ++++++++++++++++--- client/web/lib/api/shifts.ts | 27 +++++++++ client/web/types/shift-creation.ts | 1 + 7 files changed, 147 insertions(+), 13 deletions(-) diff --git a/client/web/components/calendar/day-column-overlay.tsx b/client/web/components/calendar/day-column-overlay.tsx index 30453ec..025aea5 100644 --- a/client/web/components/calendar/day-column-overlay.tsx +++ b/client/web/components/calendar/day-column-overlay.tsx @@ -96,6 +96,7 @@ export function DayColumnOverlay({ handleRoleSelected, cancelShiftCreation, handleShiftUnassignment, + handleShiftUpdate, } = useShiftCreationFlow({ restaurantId: selectedRestaurantId, scheduleId, @@ -139,15 +140,36 @@ export function DayColumnOverlay({ }; // Handle employee selection - const onEmployeeSelect = async (employee: Employee, roleId: number) => { + const onEmployeeSelect = async (employee: Employee, roleId: number, notes?: string) => { const empRoles = employeesWithRoles.get(employee.id) || []; const selectedRole = empRoles.find((r) => r.id === roleId); + // Check if there's an existing shift for this template and role on this day + let existingShift: ScheduledShift | undefined; + if (selectedTemplate) { + existingShift = shifts.find( + (s) => + s.shift_template_id === selectedTemplate.id && + s.role_id === roleId && + s.shift_date.startsWith(date) + ); + } + + if (existingShift) { + // Update existing shift + await handleShiftUpdate(existingShift, { + employee_id: employee.id, + notes: notes !== undefined ? notes : existingShift.notes, + }); + return; + } + + // Create new shift if (selectedRole && selectedTemplate) { - await handleEmployeeSelect(employee, [selectedRole]); + await handleEmployeeSelect(employee, [selectedRole], notes); setSelectedRoleId(null); } else { - await handleEmployeeSelect(employee, empRoles); + await handleEmployeeSelect(employee, empRoles, notes); } }; diff --git a/client/web/components/calendar/shift-template-overlay.tsx b/client/web/components/calendar/shift-template-overlay.tsx index 953e051..f2a6d84 100644 --- a/client/web/components/calendar/shift-template-overlay.tsx +++ b/client/web/components/calendar/shift-template-overlay.tsx @@ -13,6 +13,8 @@ import { } from "@/components/ui/popover"; import { Badge } from "@/components/ui/badge"; import { RoleColorIndicator } from "@/components/calendar/role-color-indicator"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; interface ShiftTemplateOverlayProps { template: ShiftTemplate; @@ -33,7 +35,7 @@ interface ShiftTemplateOverlayProps { employees: Employee[]; employeesWithRoles: Map; loadingRoles: boolean; - onEmployeeSelect: (employee: Employee, roleId: number) => void; + onEmployeeSelect: (employee: Employee, roleId: number, notes?: string) => void; onShiftUnassign: (shift: ScheduledShift) => void; onRoleClick: ( template: ShiftTemplate, @@ -77,6 +79,9 @@ export function ShiftTemplateOverlay({ const [pendingUnassignments, setPendingUnassignments] = useState>( new Set() ); + + // State for shift notes + const [notes, setNotes] = useState(""); // Clean up pending unassignments when assignedShifts actually updates // (employee removed from assignedShifts = unassignment succeeded) diff --git a/client/web/components/layout/sidebar-right.tsx b/client/web/components/layout/sidebar-right.tsx index c8694bf..f99b906 100644 --- a/client/web/components/layout/sidebar-right.tsx +++ b/client/web/components/layout/sidebar-right.tsx @@ -84,7 +84,7 @@ export function SidebarRight({ return ( <> - + + ) => { + if (!restaurantId) { + throw new Error("Restaurant ID is required"); + } + + setIsCreatingShift(true); + + try { + const shift = await updateScheduledShift( + restaurantId, + scheduleId, + shiftId, + payload + ); + + if (onSuccess) onSuccess(); + return shift; + } catch (error) { + const err = error instanceof Error ? error : new Error("Unknown error"); + if (onError) onError(err); + throw err; + } finally { + setIsCreatingShift(false); + } + }, + [restaurantId, onSuccess, onError] + ); + /** * Unassigns an employee from a scheduled shift * Returns the updated shift with employee_id set to null @@ -196,6 +232,7 @@ export function useScheduleManagement({ return { createScheduleForWeek, createShift, + updateShift, unassignShift, isCreatingSchedule, isCreatingShift, diff --git a/client/web/hooks/use-shift-creation-flow.tsx b/client/web/hooks/use-shift-creation-flow.tsx index fd15599..8431e82 100644 --- a/client/web/hooks/use-shift-creation-flow.tsx +++ b/client/web/hooks/use-shift-creation-flow.tsx @@ -38,10 +38,11 @@ interface UseShiftCreationFlowReturn { // Actions startShiftCreation: (template: ShiftTemplate) => void; - handleEmployeeSelect: (employee: Employee, roles: Role[]) => Promise; + handleEmployeeSelect: (employee: Employee, roles: Role[], notes?: string) => Promise; handleRoleSelected: (role: Role) => Promise; cancelShiftCreation: () => void; handleShiftUnassignment: (shift: ScheduledShift) => Promise; + handleShiftUpdate: (shift: ScheduledShift, changes: Partial) => Promise; } /** @@ -72,6 +73,7 @@ export function useShiftCreationFlow({ // Integrate with schedule management hook for API calls const { createShift, + updateShift, unassignShift, isLoading: isCreatingShift, } = useScheduleManagement({ @@ -98,7 +100,7 @@ export function useShiftCreationFlow({ * Handle immediate shift creation with optimistic UI update */ const createShiftWithOptimisticUpdate = useCallback( - async (employee: Employee, role: Role, template: ShiftTemplate) => { + async (employee: Employee, role: Role, template: ShiftTemplate, notes: string = "") => { if (!weekStartDate) return; // Generate temporary ID for optimistic update @@ -115,7 +117,7 @@ export function useShiftCreationFlow({ shift_date: date, start_time: formatTimeToHHMM(template.start_time), end_time: formatTimeToHHMM(template.end_time), - notes: "", + notes: notes, employee_name: employee.full_name, role_name: role.name, role_color: role.color, @@ -143,7 +145,7 @@ export function useShiftCreationFlow({ shift_date: date, start_time: formatTimeToHHMM(template.start_time), end_time: formatTimeToHHMM(template.end_time), - notes: "", + notes: notes, }); // Replace optimistic shift with real data @@ -178,13 +180,49 @@ export function useShiftCreationFlow({ ] ); + /** + * Handle shift update with optimistic UI update + */ + const handleShiftUpdate = useCallback( + async (shift: ScheduledShift, changes: Partial) => { + // Optimistic update + if (updateOptimisticShift) { + updateOptimisticShift(shift.id, { ...shift, ...changes }); + } + + // Clear selection state + setSelectedTemplate(null); + setRoleDialogOpen(false); + setPendingShiftData(null); + setSelectedRole(null); + + try { + await updateShift(shift.schedule_id, shift.id, { + notes: changes.notes, + // Add other fields if needed + }); + showSuccessToast("Shift updated successfully"); + } catch (error) { + console.error("Failed to update shift:", error); + // Revert optimistic update? + // For now, relies on refetch or parent to handle error state visually + // ideally we would revert using the original shift data + showErrorToast( + "Failed to update shift", + error instanceof Error ? error : new Error("Unknown error") + ); + } + }, + [updateShift, updateOptimisticShift] + ); + /** * Handle employee selection from popover * If employee has one role: create shift immediately * If employee has multiple roles: show role selector dialog */ const handleEmployeeSelect = useCallback( - async (employee: Employee, roles: Role[]) => { + async (employee: Employee, roles: Role[], notes: string = "") => { if (!selectedTemplate) return; // Check if employee has roles @@ -198,6 +236,7 @@ export function useShiftCreationFlow({ employee, template: selectedTemplate, roles, + notes, }); if (roles.length === 1) { @@ -206,7 +245,8 @@ export function useShiftCreationFlow({ await createShiftWithOptimisticUpdate( employee, roles[0], - selectedTemplate + selectedTemplate, + notes ); } else { // Multiple roles: show role selector @@ -231,7 +271,8 @@ export function useShiftCreationFlow({ await createShiftWithOptimisticUpdate( pendingShiftData.employee, role, - pendingShiftData.template + pendingShiftData.template, + pendingShiftData.notes || "" ); }, [pendingShiftData, createShiftWithOptimisticUpdate] @@ -326,5 +367,6 @@ export function useShiftCreationFlow({ handleRoleSelected, cancelShiftCreation, handleShiftUnassignment, + handleShiftUpdate, }; } diff --git a/client/web/lib/api/shifts.ts b/client/web/lib/api/shifts.ts index 829a527..ba1e6e7 100644 --- a/client/web/lib/api/shifts.ts +++ b/client/web/lib/api/shifts.ts @@ -27,3 +27,30 @@ export async function createScheduledShift( const result = await response.json(); return result.data; } + +/** + * Updates an existing scheduled shift + */ +export async function updateScheduledShift( + restaurantId: number, + scheduleId: number, + shiftId: number, + payload: Partial +): Promise { + const response = await fetchWithAuth( + `${getApiBase()}/restaurants/${restaurantId}/schedules/${scheduleId}/shifts/${shiftId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to update shift: ${errorText}`); + } + + const result = await response.json(); + return result.data; +} \ No newline at end of file diff --git a/client/web/types/shift-creation.ts b/client/web/types/shift-creation.ts index cee2c62..30ca0f2 100644 --- a/client/web/types/shift-creation.ts +++ b/client/web/types/shift-creation.ts @@ -10,6 +10,7 @@ export interface PendingShiftData { employee: Employee; template: ShiftTemplate; roles: Role[]; + notes?: string; } /**