Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions GEMINI.md
Original file line number Diff line number Diff line change
@@ -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)
83 changes: 68 additions & 15 deletions client/web/app/(resa)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -69,10 +76,19 @@ export default function ResaLayout({
<WeekNavigationProvider>
<SidebarLeft />
<SidebarInset>
<ResaHeader />
<ResaHeader
rightSidebarOpen={rightSidebarOpen}
setRightSidebarOpen={setRightSidebarOpen}
/>
<div className="flex flex-col flex-1 min-h-0">{children}</div>
</SidebarInset>
<SidebarRight />
<SidebarProvider
open={rightSidebarOpen}
onOpenChange={setRightSidebarOpen}
className="w-auto !min-h-svh"
>
<SidebarRight side="right" collapsible="offcanvas" />
</SidebarProvider>
</WeekNavigationProvider>
</SidebarProvider>
</ShiftTemplateProvider>
Expand All @@ -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();
Expand All @@ -99,7 +133,12 @@ function ResaHeader() {
<div className="flex flex-1 items-center justify-between px-3">
{/* Left side */}
<div className="flex items-center gap-2">
<SidebarTrigger />
<SidebarTrigger onClick={() => {
// If opening left sidebar on small screen, close right sidebar
if (!leftSidebarOpen && window.innerWidth < 1024 && setRightSidebarOpen) {
setRightSidebarOpen(false);
}
}} />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
Expand All @@ -118,29 +157,43 @@ function ResaHeader() {
{/* Right side - Week navigation (conditionally rendered) */}
{weekNav && (
<div className="flex items-center gap-2">
<Button variant="outline" className="h-8">
Week
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
<Button variant="outline" className="h-8">
Today
</Button>
<Button
variant="outline"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={weekNav.goToPrevWeek}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={weekNav.goToNextWeek}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Separator
orientation="vertical"
className=" data-[orientation=vertical]:h-4 ml-2"
/>
{setRightSidebarOpen && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
const newState = !rightSidebarOpen;
setRightSidebarOpen(newState);
if (newState && window.innerWidth < 1024) {
setLeftOpen(false);
}
}}
>
<PanelRight className="size-4" />
<span className="sr-only">Toggle Right Sidebar</span>
</Button>
)}
</div>
)}
</div>
Expand Down
6 changes: 3 additions & 3 deletions client/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}

Expand Down
6 changes: 5 additions & 1 deletion client/web/components/calendar/current-time-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
53 changes: 49 additions & 4 deletions client/web/components/calendar/day-column-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<number | null>(null);

// Track which template is being edited
const [editingTemplateId, setEditingTemplateId] = useState<number | null>(null);

// Filter templates for this specific day
const templatesForDay = useMemo(
Expand All @@ -90,6 +96,7 @@ export function DayColumnOverlay({
handleRoleSelected,
cancelShiftCreation,
handleShiftUnassignment,
handleShiftUpdate,
} = useShiftCreationFlow({
restaurantId: selectedRestaurantId,
scheduleId,
Expand Down Expand Up @@ -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);
}
};

Expand All @@ -156,6 +184,10 @@ export function DayColumnOverlay({
});
};

const handleTemplateClick = (template: ShiftTemplate) => {
setEditingTemplateId(template.id);
};

if (columnAssignments.length === 0) {
return null;
}
Expand All @@ -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) => {
Expand Down Expand Up @@ -215,6 +247,19 @@ export function DayColumnOverlay({
onSelectRole={handleRoleSelected}
/>
)}

{/* Edit Shift Template Dialog */}
<ShiftTemplateFormDialog
isOpen={!!editingTemplateId}
onOpenChange={(open) => !open && setEditingTemplateId(null)}
restaurantId={selectedRestaurantId}
shiftTemplateId={editingTemplateId || undefined}
mode="edit"
onSuccess={() => {
setEditingTemplateId(null);
refetchTemplates();
}}
/>
</div>
);
}
Loading