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/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/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/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/components/calendar/day-column-overlay.tsx b/client/web/components/calendar/day-column-overlay.tsx
index d0c1bd6..025aea5 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(
@@ -90,6 +96,7 @@ export function DayColumnOverlay({
handleRoleSelected,
cancelShiftCreation,
handleShiftUnassignment,
+ handleShiftUpdate,
} = useShiftCreationFlow({
restaurantId: selectedRestaurantId,
scheduleId,
@@ -133,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);
}
};
@@ -156,6 +184,10 @@ export function DayColumnOverlay({
});
};
+ const handleTemplateClick = (template: ShiftTemplate) => {
+ setEditingTemplateId(template.id);
+ };
+
if (columnAssignments.length === 0) {
return null;
}
@@ -182,7 +214,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 +247,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/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/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/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..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: "/avatars/shadcn.jpg",
-};
+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/sidebar-right.tsx b/client/web/components/layout/sidebar-right.tsx
index 48ca1cf..f99b906 100644
--- a/client/web/components/layout/sidebar-right.tsx
+++ b/client/web/components/layout/sidebar-right.tsx
@@ -83,12 +83,8 @@ export function SidebarRight({
return (
<>
-
-
+
+
{
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/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/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}
-