From e5bd458282282c3f9ef3f5ad497df23138b7cc7e Mon Sep 17 00:00:00 2001 From: CrewCircle Date: Sat, 20 Jun 2026 19:47:55 +1000 Subject: [PATCH 1/4] feat: architecture improvements - decompose RosterGrid, extract API service, add timesheets feature, vitest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RosterGrid: 775→354 lines (-54%), extracted ShiftCreationModal, useAutoSaveRoster, useRosterDragAndDrop - rosterStore: extracted rosterApi.ts (8 typed operations), store now pure state - validators: removed duplicate web-local, wired @packages/validators workspace dep - timesheets: new feature module with hooks + components (259→4 lines page wrapper) - Neon client: removed dead query() function - vitest: added config with jsdom, testing-library, mocks for Clerk/Next.js/Zustand - tests: rosterApi.test.ts (15 tests covering all 8 API functions) - migrations: superseded 20240001_core_schema.sql by 20260328 version - C4 diagrams updated, gap-analysis.md updated --- apps/web/src/api/__tests__/rosterApi.test.ts | 215 ++++++++ apps/web/src/api/rosterApi.ts | 117 +++++ apps/web/src/app/timesheets/page.tsx | 262 +--------- .../roster/components/ShiftCreationModal.tsx | 177 +++++++ .../roster/hooks/useAutoSaveRoster.ts | 57 +++ .../roster/hooks/useRosterDragAndDrop.ts | 141 ++++++ .../features/timesheets/TimesheetsPage.tsx | 100 ++++ .../timesheets/components/TimesheetTable.tsx | 81 +++ .../timesheets/hooks/useTimesheetActions.ts | 73 +++ .../timesheets/hooks/useTimesheets.ts | 99 ++++ apps/web/src/lib/neon/client.ts | 5 - apps/web/src/store/rosterStore.ts | 108 ++-- apps/web/src/types/shift.ts | 2 +- apps/web/vitest.config.ts | 21 + apps/web/vitest.setup.ts | 60 +++ docs/plans/gap-analysis.md | 467 ++++++++++++------ .../20240001_core_schema.sql.superseded | 228 +++++++++ 17 files changed, 1719 insertions(+), 494 deletions(-) create mode 100644 apps/web/src/api/__tests__/rosterApi.test.ts create mode 100644 apps/web/src/api/rosterApi.ts create mode 100644 apps/web/src/features/roster/components/ShiftCreationModal.tsx create mode 100644 apps/web/src/features/roster/hooks/useAutoSaveRoster.ts create mode 100644 apps/web/src/features/roster/hooks/useRosterDragAndDrop.ts create mode 100644 apps/web/src/features/timesheets/TimesheetsPage.tsx create mode 100644 apps/web/src/features/timesheets/components/TimesheetTable.tsx create mode 100644 apps/web/src/features/timesheets/hooks/useTimesheetActions.ts create mode 100644 apps/web/src/features/timesheets/hooks/useTimesheets.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 apps/web/vitest.setup.ts create mode 100644 supabase/migrations/20240001_core_schema.sql.superseded diff --git a/apps/web/src/api/__tests__/rosterApi.test.ts b/apps/web/src/api/__tests__/rosterApi.test.ts new file mode 100644 index 000000000..ff10bc548 --- /dev/null +++ b/apps/web/src/api/__tests__/rosterApi.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as rosterApi from '@/api/rosterApi'; + +describe('rosterApi', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('fetchCurrentRoster', () => { + it('calls GET /api/roster with tenantId and weekStart', async () => { + const mockResponse = { roster: { id: 'r1' }, shifts: [] }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await rosterApi.fetchCurrentRoster('tenant-1', '2026-01-01'); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/roster?tenantId=tenant-1&weekStart=2026-01-01' + ); + expect(result).toEqual(mockResponse); + }); + + it('throws on non-ok response', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: 'Not found' }), + }); + + await expect(rosterApi.fetchCurrentRoster('tenant-1', '2026-01-01')) + .rejects.toThrow('Not found'); + }); + }); + + describe('fetchProfiles', () => { + it('calls GET /api/profiles', async () => { + const mockResponse = { profiles: [{ id: 'p1' }] }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await rosterApi.fetchProfiles(); + + expect(global.fetch).toHaveBeenCalledWith('/api/profiles'); + expect(result).toEqual(mockResponse); + }); + }); + + describe('publishRoster', () => { + it('calls POST /api/roster with publish action', async () => { + const mockResponse = { roster: { id: 'r1', status: 'published' } }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await rosterApi.publishRoster('roster-1'); + + expect(global.fetch).toHaveBeenCalledWith('/api/roster', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'publish', rosterId: 'roster-1' }), + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('unpublishRoster', () => { + it('calls POST /api/roster with unpublish action', async () => { + const mockResponse = { roster: { id: 'r1', status: 'draft' } }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await rosterApi.unpublishRoster('roster-1'); + + expect(global.fetch).toHaveBeenCalledWith('/api/roster', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'unpublish', rosterId: 'roster-1' }), + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('copyForwardRoster', () => { + it('calls POST /api/roster with copy-forward action', async () => { + const mockResponse = { roster: { id: 'r2' } }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await rosterApi.copyForwardRoster('tenant-1', '2026-01-01', 'roster-1'); + + expect(global.fetch).toHaveBeenCalledWith('/api/roster', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'copy-forward', + tenantId: 'tenant-1', + weekStart: '2026-01-01', + rosterId: 'roster-1', + }), + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('createShift', () => { + it('calls POST /api/roster with create-shift action', async () => { + const mockResponse = { success: true }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await rosterApi.createShift({ + action: 'create-shift', + rosterId: 'r1', + profileId: 'p1', + startTime: '2026-01-01T09:00:00Z', + endTime: '2026-01-01T17:00:00Z', + }); + + expect(global.fetch).toHaveBeenCalledWith('/api/roster', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'create-shift', + rosterId: 'r1', + profileId: 'p1', + startTime: '2026-01-01T09:00:00Z', + endTime: '2026-01-01T17:00:00Z', + }), + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('updateShift', () => { + it('calls POST /api/roster with update-shift action', async () => { + const mockResponse = { success: true }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await rosterApi.updateShift({ + action: 'update-shift', + shiftId: 's1', + profileId: 'p1', + startTime: '2026-01-01T10:00:00Z', + endTime: '2026-01-01T18:00:00Z', + }); + + expect(global.fetch).toHaveBeenCalledWith('/api/roster', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'update-shift', + shiftId: 's1', + profileId: 'p1', + startTime: '2026-01-01T10:00:00Z', + endTime: '2026-01-01T18:00:00Z', + }), + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('saveShifts', () => { + it('calls POST /api/roster with save-shifts action', async () => { + const mockResponse = { success: true }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const shifts = [ + { id: 's1', profile_id: 'p1', start_time: '2026-01-01T09:00:00Z', end_time: '2026-01-01T17:00:00Z' }, + ]; + const result = await rosterApi.saveShifts({ action: 'save-shifts', shifts }); + + expect(global.fetch).toHaveBeenCalledWith('/api/roster', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'save-shifts', shifts }), + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('error handling', () => { + it('throws on network error', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + await expect(rosterApi.fetchCurrentRoster('tenant-1', '2026-01-01')) + .rejects.toThrow('Network error'); + }); + + it('throws with server error message', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: 'Internal server error' }), + }); + + await expect(rosterApi.publishRoster('roster-1')) + .rejects.toThrow('Internal server error'); + }); + }); +}); \ No newline at end of file diff --git a/apps/web/src/api/rosterApi.ts b/apps/web/src/api/rosterApi.ts new file mode 100644 index 000000000..d829aa23a --- /dev/null +++ b/apps/web/src/api/rosterApi.ts @@ -0,0 +1,117 @@ +import type { Shift } from '@/types/shift'; +import type { Roster, RosterStatus } from '@/store/rosterStore'; + +interface ApiError { + error: string; +} + +async function apiPost(url: string, body: unknown): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Request failed'); + } + + return data as T; +} + +async function apiGet(url: string): Promise { + const response = await fetch(url); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Request failed'); + } + + return data as T; +} + +export interface PublishRosterResult { + roster?: Roster; +} + +export interface CopyForwardResult { + roster: Roster; +} + +export interface FetchRosterResult { + roster: Roster | null; + shifts: Shift[]; +} + +export interface FetchProfilesResult { + profiles: import('@/types/profile').Profile[]; +} + +export async function publishRoster(rosterId: string): Promise { + return apiPost('/api/roster', { action: 'publish', rosterId }); +} + +export async function unpublishRoster(rosterId: string): Promise { + return apiPost('/api/roster', { action: 'unpublish', rosterId }); +} + +export async function copyForwardRoster( + tenantId: string, + weekStart: string, + rosterId: string +): Promise { + return apiPost('/api/roster', { + action: 'copy-forward', + tenantId, + weekStart, + rosterId, + }); +} + +export async function fetchCurrentRoster( + tenantId: string, + weekStart: string +): Promise { + return apiGet( + `/api/roster?tenantId=${encodeURIComponent(tenantId)}&weekStart=${encodeURIComponent(weekStart)}` + ); +} + +export interface CreateShiftBody { + action: 'create-shift'; + rosterId: string; + profileId: string; + startTime: string; + endTime: string; +} + +export interface UpdateShiftBody { + action: 'update-shift'; + shiftId: string; + profileId?: string; + startTime?: string; + endTime?: string; +} + +export interface SaveShiftsBody { + action: 'save-shifts'; + shifts: Array<{ id: string; profile_id: string; start_time: string; end_time: string }>; +} + +export async function createShift(body: CreateShiftBody): Promise { + return apiPost('/api/roster', body); +} + +export async function updateShift(body: UpdateShiftBody): Promise { + return apiPost('/api/roster', body); +} + +export async function saveShifts(body: SaveShiftsBody): Promise { + return apiPost('/api/roster', body); +} + +export async function fetchProfiles(): Promise { + return apiGet('/api/profiles'); +} diff --git a/apps/web/src/app/timesheets/page.tsx b/apps/web/src/app/timesheets/page.tsx index e55064d67..0a30ccc4c 100644 --- a/apps/web/src/app/timesheets/page.tsx +++ b/apps/web/src/app/timesheets/page.tsx @@ -1,259 +1,5 @@ -"use client"; - -import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { format, startOfWeek, endOfWeek } from 'date-fns'; -import { useAuth } from '@/lib/clerk/useAuth'; - -interface TimesheetEntry { - profile_id: string; - first_name: string; - last_name: string; - email: string; - work_date: string; - clock_in: string | null; - clock_out: string | null; - total_hours: number | null; - location_name: string | null; - is_within_geofence: boolean | null; - approved_at: string | null; - approved_by: string | null; -} - -export default function TimesheetsPage() { - const { user, tenantId, isDemoMode, isLoading: authLoading } = useAuth(); - const [entries, setEntries] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [dateRange, setDateRange] = useState({ - start: startOfWeek(new Date(), { weekStartsOn: 1 }), - end: endOfWeek(new Date(), { weekStartsOn: 1 }), - }); - - const fetchEntries = useCallback(async () => { - if (authLoading || !tenantId) return; - - setIsLoading(true); - try { - const start = dateRange.start.toISOString(); - const end = dateRange.end.toISOString(); - - const response = await fetch( - `/api/timesheets?tenantId=${tenantId}&start=${start}&end=${end}` - ); - - if (!response.ok) { - throw new Error('Failed to fetch timesheets'); - } - - const data = await response.json(); - setEntries(data.entries || []); - } catch (error) { - console.error('Error fetching timesheet entries:', error); - } finally { - setIsLoading(false); - } - }, [tenantId, dateRange, authLoading]); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - fetchEntries(); - }, [fetchEntries]); - - const groupedEntries = useMemo(() => { - const grouped: Record = {}; - entries.forEach((entry) => { - const date = entry.work_date; - if (!grouped[date]) grouped[date] = []; - grouped[date].push(entry); - }); - return grouped; - }, [entries]); - - const totalHours = useMemo(() => { - return entries.reduce((sum, entry) => sum + (entry.total_hours || 0), 0); - }, [entries]); - - const handleApprove = async (profileId: string, workDate: string) => { - if (!tenantId) return; - try { - await fetch('/api/timesheets/approve', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ profileId, workDate, tenantId }), - }); - fetchEntries(); - } catch (error) { - console.error('Approve error:', error); - } - }; - - const handleApproveAll = async () => { - if (!tenantId) return; - const unapproved = entries.filter(e => !e.approved_at); - for (const entry of unapproved) { - await handleApprove(entry.profile_id, entry.work_date); - } - }; - - const handleExportCSV = () => { - if (entries.length === 0) return; - - const headers = 'Employee Name,Email,Date,Start,End,Hours,Location,Geofence,Approved\n'; - const rows = entries.map(entry => { - const date = entry.work_date ? format(new Date(entry.work_date), 'dd/MM/yyyy') : ''; - const clockIn = entry.clock_in ? format(new Date(entry.clock_in), 'HH:mm') : ''; - const clockOut = entry.clock_out ? format(new Date(entry.clock_out), 'HH:mm') : 'Open'; - return [ - `"${entry.first_name} ${entry.last_name}"`, - entry.email, - date, - clockIn, - clockOut, - entry.total_hours?.toFixed(2) || '', - `"${entry.location_name || ''}"`, - entry.is_within_geofence ? 'Yes' : 'No', - entry.approved_at ? 'Yes' : 'No', - ].join(','); - }).join('\n'); - - const csv = headers + rows; - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `timesheets-${format(dateRange.start, 'yyyy-MM-dd')}.csv`; - a.click(); - URL.revokeObjectURL(url); - }; - - if (authLoading || (!tenantId && !isDemoMode)) { - return
Loading...
; - } - - if (!tenantId) { - return
Not authenticated
; - } - - const unapprovedCount = entries.filter(e => !e.approved_at).length; - - return ( -
-
-

Timesheets

-
- - -
-
- -
-
-
- {format(dateRange.start, 'MMM d')} - {format(dateRange.end, 'MMM d, yyyy')} -
-
- Total: {totalHours.toFixed(1)} hours -
- {unapprovedCount > 0 && ( - - )} - -
-
-
-
- - {isLoading ? ( -
Loading timesheets...
- ) : entries.length === 0 ? ( -
No timesheet entries found for this period.
- ) : ( -
- {Object.entries(groupedEntries).map(([date, dayEntries]) => ( -
-
- {format(new Date(date), 'EEEE, MMMM d, yyyy')} - - {dayEntries.reduce((sum, e) => sum + (e.total_hours || 0), 0).toFixed(1)}h - -
-
- {dayEntries.map((entry, idx) => ( -
-
-
- {entry.first_name?.[0] || '?'} -
-
-

- {entry.first_name} {entry.last_name} -

-

- {entry.location_name || 'No location'} -

-
-
-
-
-

- {entry.clock_in && format(new Date(entry.clock_in), 'h:mm a')} - {entry.clock_out && ` - ${format(new Date(entry.clock_out), 'h:mm a')}`} - {!entry.clock_in && Not clocked in} -

-

- {entry.total_hours?.toFixed(1) || '0'} hours - {entry.is_within_geofence && ( - ✓ GPS verified - )} -

-
- {entry.approved_at ? ( - - ✓ Approved - - ) : ( - - )} -
-
- ))} -
-
- ))} -
- )} -
- ); -} +import TimesheetsPage from '@/features/timesheets/TimesheetsPage'; +export default function TimesheetsPageWrapper() { + return ; +} \ No newline at end of file diff --git a/apps/web/src/features/roster/components/ShiftCreationModal.tsx b/apps/web/src/features/roster/components/ShiftCreationModal.tsx new file mode 100644 index 000000000..e4388abf8 --- /dev/null +++ b/apps/web/src/features/roster/components/ShiftCreationModal.tsx @@ -0,0 +1,177 @@ +"use client"; + +import React, { useState } from 'react'; +import { z } from 'zod'; +import type { Profile } from '@/types/profile'; + +export interface ShiftFormData { + employeeId: string; + startTime: string; + endTime: string; + roleLabel: string; + notes: string; +} + +const shiftCreationSchema = z.object({ + employeeId: z.string(), + startTime: z.string().refine((val) => !isNaN(Date.parse(val)), 'Invalid start time'), + endTime: z.string().refine((val) => !isNaN(Date.parse(val)), 'Invalid end time'), + roleLabel: z.string().optional(), + notes: z.string().optional(), +}); + +interface ShiftCreationModalProps { + open: boolean; + onClose: () => void; + onSave: (shiftData: z.infer) => void; + employees: Profile[]; +} + +const ShiftCreationModal: React.FC = ({ + open, + onClose, + onSave, + employees, +}) => { + const [formData, setFormData] = useState({ + employeeId: '', + startTime: '', + endTime: '', + roleLabel: '', + notes: '', + }); + const [errors, setErrors] = useState | null>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + if (errors?.[name]) { + setErrors((prev) => { + const next = { ...prev }; + delete next[name]; + return next; + }); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + try { + const parsed = shiftCreationSchema.parse(formData); + onSave(parsed); + onClose(); + } catch (err) { + if (err instanceof z.ZodError) { + const errorMap: Record = {}; + err.issues.forEach((issue) => { + if (issue.path.length > 0) { + errorMap[issue.path[0] as string] = issue.message; + } + }); + setErrors(errorMap); + } + } finally { + setIsSubmitting(false); + } + }; + + if (!open) return null; + + return ( +
+
+

Add Shift

+
+
+ + + {errors?.employeeId && ( +

{errors.employeeId}

+ )} +
+
+ + + {errors?.startTime && ( +

{errors.startTime}

+ )} +
+
+ + + {errors?.endTime && ( +

{errors.endTime}

+ )} +
+
+ + +
+
+ +