diff --git a/.env.example b/.env.example index 42abed958..294b4a140 100644 --- a/.env.example +++ b/.env.example @@ -4,21 +4,12 @@ # Copy this file to .env.local and fill in the values # =========================================== -# CLERK AUTHENTICATION +# SUPABASE # =========================================== -# Get from: https://dashboard.clerk.com -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... -CLERK_SECRET_KEY=sk_test_... -NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login -NEXT_PUBLIC_CLERK_SIGN_UP_URL=/signup -NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/roster -NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/roster - -# =========================================== -# NEON DATABASE -# =========================================== -# Get from: https://neondb.tech -DATABASE_URL=postgresql://user:password@host/database?sslmode=require +# Get from: https://supabase.com/dashboard +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... +SUPABASE_SERVICE_ROLE_KEY=eyJ... # Server only — never expose to client # =========================================== # STRIPE CONFIGURATION @@ -43,9 +34,3 @@ STRIPE_PRICE_ID=price_... # Your production site URL NEXT_PUBLIC_SITE_URL=https://roster.crewcircle.co - -# =========================================== -# VERTCEL (auto-injected, do not edit) -# =========================================== -VERCEL_OIDC_TOKEN= -VERCEL= diff --git a/apps/mobile/app/(tabs)/availability.tsx b/apps/mobile/app/(tabs)/availability.tsx index b52687d97..971fb5aaa 100644 --- a/apps/mobile/app/(tabs)/availability.tsx +++ b/apps/mobile/app/(tabs)/availability.tsx @@ -36,6 +36,20 @@ export default function AvailabilityScreen() { }; const toggleAvailability = async (dayIndex: number) => { + if (!user) return; + + // Look up tenant_id from user's profile + const { data: profileData } = await supabase + .from('profiles') + .select('tenant_id') + .eq('id', user.id) + .single(); + + if (!profileData?.tenant_id) { + console.error('Could not determine tenant_id for availability insert'); + return; + } + const existing = availabilities.find(a => a.day_of_week === dayIndex); if (existing) { @@ -58,7 +72,7 @@ export default function AvailabilityScreen() { const { data, error } = await supabase .from('availability') - .insert({ ...newAvailability, profile_id: user?.id }) + .insert({ ...newAvailability, profile_id: user.id, tenant_id: profileData.tenant_id }) .select(); if (!error && data) { diff --git a/apps/mobile/context/AuthContext.tsx b/apps/mobile/context/AuthContext.tsx index 91d1de272..edb9f8e5c 100644 --- a/apps/mobile/context/AuthContext.tsx +++ b/apps/mobile/context/AuthContext.tsx @@ -74,10 +74,25 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const registerPushToken = async (token: string) => { try { + if (!session?.user?.id) return; + + // Look up tenant_id from profile + const { data: profile } = await supabase + .from('profiles') + .select('tenant_id') + .eq('id', session.user.id) + .single(); + + if (!profile?.tenant_id) { + console.error('Could not find tenant_id for push token registration'); + return; + } + const { data: existingToken, error: fetchError } = await supabase .from('push_tokens') .select('id') - .eq('profile_id', session?.user?.id) + .eq('profile_id', session.user.id) + .is('deleted_at', null) .single(); if (fetchError && fetchError.code !== 'PGRST116') { @@ -94,6 +109,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { .update({ expo_push_token: token, updated_at: new Date().toISOString(), + deleted_at: null, }) .eq('id', existingToken.id); @@ -104,7 +120,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const { error: insertError } = await supabase .from('push_tokens') .insert({ - profile_id: session?.user?.id, + profile_id: session.user.id, + tenant_id: profile.tenant_id, expo_push_token: token, platform, }); @@ -123,8 +140,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { if (session?.user?.id) { const { error } = await supabase .from('push_tokens') - .delete() - .eq('profile_id', session?.user?.id); + .update({ deleted_at: new Date().toISOString() }) + .eq('profile_id', session.user.id) + .is('deleted_at', null); if (error) { console.error('Error removing push token:', error); diff --git a/apps/web/package.json b/apps/web/package.json index faaadbee2..390becd1f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,17 +12,14 @@ }, "dependencies": { "@packages/validators": "workspace:*", - "@clerk/nextjs": "^7.5.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", - "@neondatabase/serverless": "^1.1.0", "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.108.1", "@tanstack/react-virtual": "^3.14.2", "date-fns": "^4.4.0", "immer": "^11.1.8", "next": "16.2.9", - "postgres": "^3.4.9", "react": "19.2.7", "react-dom": "19.2.7", "stripe": "^22.2.0", diff --git a/apps/web/src/api/__tests__/rosterApi.test.ts b/apps/web/src/api/__tests__/rosterApi.test.ts index ff10bc548..5d5a9e4f7 100644 --- a/apps/web/src/api/__tests__/rosterApi.test.ts +++ b/apps/web/src/api/__tests__/rosterApi.test.ts @@ -7,17 +7,17 @@ describe('rosterApi', () => { }); describe('fetchCurrentRoster', () => { - it('calls GET /api/roster with tenantId and weekStart', async () => { + it('calls GET /api/roster with 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'); + const result = await rosterApi.fetchCurrentRoster('2026-01-01'); expect(global.fetch).toHaveBeenCalledWith( - '/api/roster?tenantId=tenant-1&weekStart=2026-01-01' + '/api/roster?weekStart=2026-01-01' ); expect(result).toEqual(mockResponse); }); @@ -28,7 +28,7 @@ describe('rosterApi', () => { json: () => Promise.resolve({ error: 'Not found' }), }); - await expect(rosterApi.fetchCurrentRoster('tenant-1', '2026-01-01')) + await expect(rosterApi.fetchCurrentRoster('2026-01-01')) .rejects.toThrow('Not found'); }); }); @@ -94,14 +94,13 @@ describe('rosterApi', () => { json: () => Promise.resolve(mockResponse), }); - const result = await rosterApi.copyForwardRoster('tenant-1', '2026-01-01', 'roster-1'); + const result = await rosterApi.copyForwardRoster('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', }), @@ -198,7 +197,7 @@ describe('rosterApi', () => { it('throws on network error', async () => { global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); - await expect(rosterApi.fetchCurrentRoster('tenant-1', '2026-01-01')) + await expect(rosterApi.fetchCurrentRoster('2026-01-01')) .rejects.toThrow('Network error'); }); diff --git a/apps/web/src/api/rosterApi.ts b/apps/web/src/api/rosterApi.ts index d829aa23a..4840d0190 100644 --- a/apps/web/src/api/rosterApi.ts +++ b/apps/web/src/api/rosterApi.ts @@ -58,24 +58,21 @@ export async function unpublishRoster(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)}` + `/api/roster?weekStart=${encodeURIComponent(weekStart)}` ); } diff --git a/apps/web/src/app/api/checkout/route.ts b/apps/web/src/app/api/checkout/route.ts index b7911033d..a44d9ed61 100644 --- a/apps/web/src/app/api/checkout/route.ts +++ b/apps/web/src/app/api/checkout/route.ts @@ -1,32 +1,49 @@ -import { NextResponse } from "next/server"; -import Stripe from "stripe"; +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/supabase/server'; +import Stripe from 'stripe'; const getStripe = () => { if ( !process.env.STRIPE_SECRET_KEY || - process.env.STRIPE_SECRET_KEY === "sk_test_placeholder" + process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder' ) { return null; } - return new Stripe(process.env.STRIPE_SECRET_KEY, { - apiVersion: "2026-05-27.dahlia" as const, - }); + return new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2026-05-27.dahlia' as const, + }); }; export async function POST(req: Request) { const stripe = getStripe(); if (!stripe) { return NextResponse.json( - { error: "Stripe not configured" }, + { error: 'Stripe not configured' }, { status: 503 }, ); } try { - const { tenantId, email } = await req.json(); + const { user, client } = await requireAuth(); + const { email } = await req.json(); + + // Resolve tenantId from the authenticated user's profile + const { data: profile } = await client + .from('profiles') + .select('tenant_id, role') + .eq('id', user.id) + .single(); + + if (!profile || !profile.tenant_id) { + return NextResponse.json({ error: 'Profile not found' }, { status: 404 }); + } + + if (!['owner', 'admin'].includes(profile.role)) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } const session = await stripe.checkout.sessions.create({ - payment_method_types: ["card", "au_becs_debit"], + payment_method_types: ['card', 'au_becs_debit'], customer_email: email, line_items: [ { @@ -34,21 +51,25 @@ export async function POST(req: Request) { quantity: 1, }, ], - mode: "subscription", + mode: 'subscription', success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing?success=true`, cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing?canceled=true`, metadata: { - tenantId: tenantId, + tenantId: profile.tenant_id, }, subscription_data: { metadata: { - tenantId: tenantId, + tenantId: profile.tenant_id, }, }, }); return NextResponse.json({ sessionId: session.id, url: session.url }); - } catch (err: any) { - return NextResponse.json({ error: err.message }, { status: 500 }); + } catch (err: unknown) { + if (err instanceof Error && err.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const message = err instanceof Error ? err.message : 'Unknown error'; + return NextResponse.json({ error: message }, { status: 500 }); } } diff --git a/apps/web/src/app/api/demo/login/route.ts b/apps/web/src/app/api/demo/login/route.ts index 057d3d4e9..6ce227b4b 100644 --- a/apps/web/src/app/api/demo/login/route.ts +++ b/apps/web/src/app/api/demo/login/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { randomBytes } from 'crypto'; +import { createAdminClient } from '@/lib/supabase/admin'; const DEMO_EMAILS = [ 'demo-owner@crewcircle.co', @@ -9,6 +9,8 @@ const DEMO_EMAILS = [ 'demo-pilot@crewcircle.co', ]; +const DEMO_PASSWORD = 'crewcircle-demo-2026'; + export async function POST(request: NextRequest) { try { const { email, role, tenantId } = await request.json(); @@ -17,47 +19,37 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Email required' }, { status: 400 }); } - if (DEMO_EMAILS.includes(email)) { - const token = `demo_${randomBytes(32).toString('hex')}`; - - return NextResponse.json({ - success: true, - token, - userId: `demo_${email.split('@')[0]}`, - role, - tenantId, - }); + if (!DEMO_EMAILS.includes(email)) { + return NextResponse.json({ error: 'Not a demo user' }, { status: 400 }); } - const { clerkClient } = await import('@clerk/nextjs/server'); - const clerk = await clerkClient(); - - const users = await clerk.users.getUserList({ - emailAddress: [email], - }); + const adminClient = createAdminClient(); - if (users.data.length === 0) { - return NextResponse.json({ error: 'User not found' }, { status: 404 }); + // Ensure the demo user exists in Supabase Auth + try { + await adminClient.auth.admin.createUser({ + email, + password: DEMO_PASSWORD, + email_confirm: true, + user_metadata: { role, tenant_id: tenantId }, + }); + } catch (err) { + // User likely already exists — that's fine } - const userId = users.data[0].id; - - const signInToken = await clerk.signInTokens.createSignInToken({ - userId, - expiresInSeconds: 3600, - }); - + // Return credentials for client-side login return NextResponse.json({ success: true, - token: signInToken.token, - userId, + email, + password: DEMO_PASSWORD, + userId: `demo_${email.split('@')[0]}`, role, tenantId, }); } catch (error) { console.error('Demo login error:', error); return NextResponse.json({ - error: 'Failed to create sign-in token. ' + (error instanceof Error ? error.message : 'Unknown error'), + error: 'Failed to process demo login. ' + (error instanceof Error ? error.message : 'Unknown error'), }, { status: 500 }); } } diff --git a/apps/web/src/app/api/demo/route.ts b/apps/web/src/app/api/demo/route.ts index ca83ed445..4eded1712 100644 --- a/apps/web/src/app/api/demo/route.ts +++ b/apps/web/src/app/api/demo/route.ts @@ -1,4 +1,4 @@ -import { sql } from '@/lib/neon/client'; +import { createAdminClient } from '@/lib/supabase/admin'; import { randomUUID } from 'crypto'; const DEMO_USERS = [ @@ -27,73 +27,110 @@ function getCurrentWeekMonday(): Date { export async function POST() { try { + const supabase = createAdminClient(); + // Check if demo tenant already exists - const existingTenants = await sql` - SELECT id FROM tenants WHERE name = 'The Daily Grind Cafe' AND deleted_at IS NULL LIMIT 1 - `; + const { data: existingTenants } = await supabase + .from('tenants') + .select('id') + .eq('name', 'The Daily Grind Cafe') + .is('deleted_at', null) + .limit(1); + let tenantId: string; let isNew = false; - if (existingTenants.length > 0) { + if (existingTenants && existingTenants.length > 0) { tenantId = existingTenants[0].id; } else { isNew = true; - const tenantResult = await sql` - INSERT INTO tenants (name, abn, timezone, plan) - VALUES ('The Daily Grind Cafe', '51824753556', 'Australia/Sydney', 'free') - RETURNING id - `; - tenantId = tenantResult[0].id; + const { data: tenantResult } = await supabase + .from('tenants') + .insert({ name: 'The Daily Grind Cafe', abn: '51824753556', timezone: 'Australia/Sydney', plan: 'free' }) + .select('id') + .single(); + + if (!tenantResult) { + return Response.json({ error: 'Failed to create demo tenant' }, { status: 500 }); + } + tenantId = tenantResult.id; } // Create location if it doesn't exist - const existingLocations = await sql` - SELECT id FROM locations WHERE tenant_id = ${tenantId} AND name = 'Main Cafe - Surry Hills' AND deleted_at IS NULL LIMIT 1 - `; + const { data: existingLocations } = await supabase + .from('locations') + .select('id') + .eq('tenant_id', tenantId) + .eq('name', 'Main Cafe - Surry Hills') + .is('deleted_at', null) + .limit(1); + let locationId: string; - if (existingLocations.length > 0) { + if (existingLocations && existingLocations.length > 0) { locationId = existingLocations[0].id; } else { - const locationResult = await sql` - INSERT INTO locations (tenant_id, name, address, latitude, longitude, timezone, geofence_radius_m) - VALUES (${tenantId}, 'Main Cafe - Surry Hills', '42 Crown Street, Surry Hills NSW 2010', -33.8833, 151.2167, 'Australia/Sydney', 150) - RETURNING id - `; - locationId = locationResult[0].id; + const { data: locationResult } = await supabase + .from('locations') + .insert({ + tenant_id: tenantId, + name: 'Main Cafe - Surry Hills', + address: '42 Crown Street, Surry Hills NSW 2010', + latitude: -33.8833, + longitude: 151.2167, + timezone: 'Australia/Sydney', + geofence_radius_m: 150, + }) + .select('id') + .single(); + + if (!locationResult) { + return Response.json({ error: 'Failed to create demo location' }, { status: 500 }); + } + locationId = locationResult.id; } // Create roster for current week const monday = getCurrentWeekMonday(); - // Use local date string to avoid UTC date shift (AEST → UTC is -1 day) const weekStart = `${monday.getFullYear()}-${String(monday.getMonth() + 1).padStart(2, '0')}-${String(monday.getDate()).padStart(2, '0')}`; - const existingRosters = await sql` - SELECT id FROM rosters WHERE tenant_id = ${tenantId} AND week_start = ${weekStart} AND deleted_at IS NULL LIMIT 1 - `; + const { data: existingRosters } = await supabase + .from('rosters') + .select('id') + .eq('tenant_id', tenantId) + .eq('week_start', weekStart) + .is('deleted_at', null) + .limit(1); + let rosterId: string; - if (existingRosters.length > 0) { + if (existingRosters && existingRosters.length > 0) { rosterId = existingRosters[0].id; - // Ensure the roster is published (roster API may have created it as draft) - await sql`UPDATE rosters SET status = 'published' WHERE id = ${rosterId}`; + await supabase.from('rosters').update({ status: 'published' }).eq('id', rosterId); } else { - const rosterResult = await sql` - INSERT INTO rosters (tenant_id, location_id, week_start, status) - VALUES (${tenantId}, ${locationId}, ${weekStart}, 'published') - RETURNING id - `; - rosterId = rosterResult[0].id; + const { data: rosterResult } = await supabase + .from('rosters') + .insert({ tenant_id: tenantId, location_id: locationId, week_start: weekStart, status: 'published' }) + .select('id') + .single(); + + if (!rosterResult) { + return Response.json({ error: 'Failed to create demo roster' }, { status: 500 }); + } + rosterId = rosterResult.id; } // Create profiles if they don't exist - const existingProfiles = await sql` - SELECT id, email FROM profiles WHERE tenant_id = ${tenantId} AND deleted_at IS NULL - `; - const existingEmails = new Set(existingProfiles.map((p: any) => p.email)); + const { data: existingProfiles } = await supabase + .from('profiles') + .select('id, email') + .eq('tenant_id', tenantId) + .is('deleted_at', null); + + const existingEmails = new Set((existingProfiles ?? []).map((p: Record) => p.email)); const profileMap = new Map(); - for (const p of existingProfiles) { + for (const p of existingProfiles ?? []) { const user = DEMO_USERS.find((u) => u.email === p.email); if (user) { profileMap.set(p.email, { id: p.id, ...user }); @@ -103,38 +140,41 @@ export async function POST() { for (const user of DEMO_USERS) { if (!existingEmails.has(user.email)) { const profileId = randomUUID(); - await sql` - INSERT INTO profiles (id, tenant_id, role, first_name, last_name, email) - VALUES (${profileId}, ${tenantId}, ${user.role}, ${user.firstName}, ${user.lastName}, ${user.email}) - `; + await supabase.from('profiles').insert({ + id: profileId, + tenant_id: tenantId, + role: user.role, + first_name: user.firstName, + last_name: user.lastName, + email: user.email, + }); profileMap.set(user.email, { id: profileId, ...user }); } } // Create demo shifts if none exist for this roster - const existingShifts = await sql` - SELECT id FROM shifts WHERE roster_id = ${rosterId} AND deleted_at IS NULL LIMIT 1 - `; - - if (existingShifts.length === 0) { + const { data: existingShifts } = await supabase + .from('shifts') + .select('id') + .eq('roster_id', rosterId) + .is('deleted_at', null) + .limit(1); + + if (!existingShifts || existingShifts.length === 0) { const shiftDefs: ShiftDef[] = [ - // Maria (owner) - Mon-Fri 9am-5pm { profileEmail: 'demo-owner@crewcircle.co', dayOffset: 0, startHour: 9, endHour: 17, roleLabel: 'Owner' }, { profileEmail: 'demo-owner@crewcircle.co', dayOffset: 1, startHour: 9, endHour: 17, roleLabel: 'Owner' }, { profileEmail: 'demo-owner@crewcircle.co', dayOffset: 2, startHour: 9, endHour: 17, roleLabel: 'Owner' }, { profileEmail: 'demo-owner@crewcircle.co', dayOffset: 3, startHour: 9, endHour: 17, roleLabel: 'Owner' }, { profileEmail: 'demo-owner@crewcircle.co', dayOffset: 4, startHour: 9, endHour: 17, roleLabel: 'Owner' }, - // Jake (manager) - Mon-Fri 7am-3pm { profileEmail: 'demo-manager@crewcircle.co', dayOffset: 0, startHour: 7, endHour: 15, roleLabel: 'Manager' }, { profileEmail: 'demo-manager@crewcircle.co', dayOffset: 1, startHour: 7, endHour: 15, roleLabel: 'Manager' }, { profileEmail: 'demo-manager@crewcircle.co', dayOffset: 2, startHour: 7, endHour: 15, roleLabel: 'Manager' }, { profileEmail: 'demo-manager@crewcircle.co', dayOffset: 3, startHour: 7, endHour: 15, roleLabel: 'Manager' }, { profileEmail: 'demo-manager@crewcircle.co', dayOffset: 4, startHour: 7, endHour: 15, roleLabel: 'Manager' }, - // Sarah (employee barista) - Mon, Wed, Fri 8am-2pm { profileEmail: 'demo-employee1@crewcircle.co', dayOffset: 0, startHour: 8, endHour: 14, roleLabel: 'Barista' }, { profileEmail: 'demo-employee1@crewcircle.co', dayOffset: 2, startHour: 8, endHour: 14, roleLabel: 'Barista' }, { profileEmail: 'demo-employee1@crewcircle.co', dayOffset: 4, startHour: 8, endHour: 14, roleLabel: 'Barista' }, - // Emma (employee waitstaff) - Tue, Thu, Sat 10am-4pm { profileEmail: 'demo-employee2@crewcircle.co', dayOffset: 1, startHour: 10, endHour: 16, roleLabel: 'Waitstaff' }, { profileEmail: 'demo-employee2@crewcircle.co', dayOffset: 3, startHour: 10, endHour: 16, roleLabel: 'Waitstaff' }, { profileEmail: 'demo-employee2@crewcircle.co', dayOffset: 5, startHour: 10, endHour: 16, roleLabel: 'Waitstaff' }, @@ -148,88 +188,57 @@ export async function POST() { shiftDate.setDate(shiftDate.getDate() + shift.dayOffset); const shiftDateStr = shiftDate.toISOString().split('T')[0]; - // Use AEST (+10:00) for Sydney cafe shifts const startTime = new Date(`${shiftDateStr}T${String(shift.startHour).padStart(2, '0')}:00:00+10:00`); const endTime = new Date(`${shiftDateStr}T${String(shift.endHour).padStart(2, '0')}:00:00+10:00`); - await sql` - INSERT INTO shifts (tenant_id, location_id, roster_id, profile_id, start_time, end_time, role_label) - VALUES (${tenantId}, ${locationId}, ${rosterId}, ${profile.id}, ${startTime.toISOString()}, ${endTime.toISOString()}, ${shift.roleLabel}) - `; + await supabase.from('shifts').insert({ + tenant_id: tenantId, + location_id: locationId, + roster_id: rosterId, + profile_id: profile.id, + start_time: startTime.toISOString(), + end_time: endTime.toISOString(), + role_label: shift.roleLabel, + }); } - } - - // Create clock events for today's shifts (if weekday) - const today = new Date(); - // Calculate today's date in Australia/Sydney timezone - const todaySydney = today.toLocaleString('en-US', { timeZone: 'Australia/Sydney', hour12: false }); - const [todayMonth, todayDay, todayYear] = todaySydney.split(',')[0].split('/').map(Number); - const todayDateStr = `${todayYear}-${String(todayMonth).padStart(2, '0')}-${String(todayDay).padStart(2, '0')}`; - const todayDayOfWeek = new Date(`${todayDateStr}T00:00:00+10:00`).getDay(); // 0=Sun, 1=Mon... - - if (todayDayOfWeek >= 1 && todayDayOfWeek <= 5) { - // Get today's shifts for this roster (match Sydney date) - const todaysShifts = await sql` - SELECT id, profile_id, start_time, end_time FROM shifts - WHERE roster_id = ${rosterId} AND DATE(start_time AT TIME ZONE 'Australia/Sydney') = ${todayDateStr} - AND deleted_at IS NULL - `; - - for (const shift of todaysShifts) { - const clockInTime = new Date(shift.start_time); - const clockOutTime = new Date(shift.end_time); - - // Clock in event with idempotency_key (random UUID - ON CONFLICT DO NOTHING for re-runs) - await sql` - INSERT INTO clock_events (tenant_id, location_id, profile_id, shift_id, type, recorded_at, latitude, longitude, accuracy_m, is_within_geofence, source, idempotency_key) - VALUES (${tenantId}, ${locationId}, ${shift.profile_id}, ${shift.id}, 'clock_in', ${clockInTime.toISOString()}, -33.8833, 151.2167, 10, true, 'mobile', gen_random_uuid()) - ON CONFLICT (idempotency_key) DO NOTHING - `; - - // Clock out event with idempotency_key - await sql` - INSERT INTO clock_events (tenant_id, location_id, profile_id, shift_id, type, recorded_at, latitude, longitude, accuracy_m, is_within_geofence, source, idempotency_key) - VALUES (${tenantId}, ${locationId}, ${shift.profile_id}, ${shift.id}, 'clock_out', ${clockOutTime.toISOString()}, -33.8833, 151.2167, 10, true, 'mobile', gen_random_uuid()) - ON CONFLICT (idempotency_key) DO NOTHING - `; - } - } - - // Create availability for all demo profiles - const availabilityDefs = [ - // Maria (owner) - available Mon-Fri 9am-6pm - { email: 'demo-owner@crewcircle.co', days: [1,2,3,4,5], start: '09:00', end: '18:00' }, - // Jake (manager) - available Mon-Fri 7am-4pm - { email: 'demo-manager@crewcircle.co', days: [1,2,3,4,5], start: '07:00', end: '16:00' }, - // Sarah (barista) - available Mon, Wed, Fri 8am-3pm - { email: 'demo-employee1@crewcircle.co', days: [1,3,5], start: '08:00', end: '15:00' }, - // Emma (waitstaff) - available Tue, Thu, Sat 10am-5pm - { email: 'demo-employee2@crewcircle.co', days: [2,4,6], start: '10:00', end: '17:00' }, - ]; - - for (const avail of availabilityDefs) { - const profile = profileMap.get(avail.email); - if (!profile) continue; + } - for (const dayOfWeek of avail.days) { - await sql` - INSERT INTO availability (tenant_id, profile_id, day_of_week, start_time, end_time, is_available) - VALUES (${tenantId}, ${profile.id}, ${dayOfWeek}, ${avail.start}, ${avail.end}, true) - ON CONFLICT (tenant_id, profile_id, day_of_week) DO UPDATE SET - start_time = EXCLUDED.start_time, - end_time = EXCLUDED.end_time, - is_available = EXCLUDED.is_available - `; - } + // Create availability for all demo profiles + const availabilityDefs = [ + { email: 'demo-owner@crewcircle.co', days: [1, 2, 3, 4, 5], start: '09:00', end: '18:00' }, + { email: 'demo-manager@crewcircle.co', days: [1, 2, 3, 4, 5], start: '07:00', end: '16:00' }, + { email: 'demo-employee1@crewcircle.co', days: [1, 3, 5], start: '08:00', end: '15:00' }, + { email: 'demo-employee2@crewcircle.co', days: [2, 4, 6], start: '10:00', end: '17:00' }, + ]; + + for (const avail of availabilityDefs) { + const profile = profileMap.get(avail.email); + if (!profile) continue; + + for (const dayOfWeek of avail.days) { + await supabase.from('availability').upsert( + { + tenant_id: tenantId, + profile_id: profile.id, + day_of_week: dayOfWeek, + start_time: avail.start, + end_time: avail.end, + is_available: true, + }, + { onConflict: 'tenant_id,profile_id,day_of_week' }, + ); } + } - return Response.json({ + return Response.json({ success: true, message: isNew ? 'Demo organization created successfully' : 'Demo organization ready', tenantId, }); } catch (error) { console.error('Error setting up demo:', error); - return Response.json({ error: 'Failed to set up demo: ' + (error instanceof Error ? error.message : 'Unknown error') }, { status: 500 }); + return Response.json({ + error: 'Failed to set up demo: ' + (error instanceof Error ? error.message : 'Unknown error'), + }, { status: 500 }); } } diff --git a/apps/web/src/app/api/invite/route.ts b/apps/web/src/app/api/invite/route.ts index acd8f7764..b89e39a51 100644 --- a/apps/web/src/app/api/invite/route.ts +++ b/apps/web/src/app/api/invite/route.ts @@ -1,68 +1,106 @@ import { NextResponse } from 'next/server'; -import { clerkClient } from '@clerk/nextjs/server'; -import { auth } from '@clerk/nextjs/server'; -import { sql } from '@/lib/neon/client'; +import { requireAuth } from '@/lib/supabase/server'; +import { createAdminClient } from '@/lib/supabase/admin'; export async function POST(request: Request) { try { - const { userId } = await auth(); - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - + const { user, client } = await requireAuth(); const { email, role } = await request.json(); if (!email || !role) { return NextResponse.json({ error: 'Email and role are required' }, { status: 400 }); } - const profile = await sql` - SELECT tenant_id, role - FROM profiles - WHERE id = ${userId} - `; + // Verify the inviter has owner/manager role + const { data: profile } = await client + .from('profiles') + .select('tenant_id, role') + .eq('id', user.id) + .single(); - if (profile.length === 0) { + if (!profile) { return NextResponse.json({ error: 'Profile not found' }, { status: 404 }); } - if (!['owner', 'manager'].includes(profile[0].role)) { + if (!['owner', 'manager'].includes(profile.role)) { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); } - const tenantId = profile[0].tenant_id; + const tenantId = profile.tenant_id; - const tenant = await sql` - SELECT plan FROM tenants WHERE id = ${tenantId} - `; + // Check free tier limits + const { data: tenant } = await client + .from('tenants') + .select('plan') + .eq('id', tenantId) + .single(); - if (tenant.length > 0 && tenant[0].plan !== 'starter') { - const employeeCount = await sql` - SELECT COUNT(*) as count FROM profiles - WHERE tenant_id = ${tenantId} AND deleted_at IS NULL - `; + if (tenant && tenant.plan !== 'starter') { + const { count } = await client + .from('profiles') + .select('*', { count: 'exact', head: true }) + .eq('tenant_id', tenantId) + .is('deleted_at', null); - if (employeeCount[0]?.count >= 5) { + if (count !== null && count >= 5) { return NextResponse.json( { error: 'Free tier limit reached (5 employees). Please upgrade to add more.' }, - { status: 403 } + { status: 403 }, ); } } - const clerk = await clerkClient(); - await clerk.invitations.createInvitation({ - emailAddress: email, - redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/accept-invite`, - publicMetadata: { + // Create invited user via Supabase Admin API + const adminClient = createAdminClient(); + + const { data: existingUser } = await adminClient + .from('profiles') + .select('id') + .eq('email', email) + .single(); + + if (existingUser) { + return NextResponse.json({ error: 'A user with this email already exists' }, { status: 409 }); + } + + const { data: newUser, error: inviteError } = await adminClient.auth.admin.inviteUserByEmail( + email, + { + data: { + role, + tenant_id: tenantId, + invited_by: user.id, + }, + redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/accept-invite`, + }, + ); + + if (inviteError) { + console.error('Invite error:', inviteError); + return NextResponse.json({ error: 'Failed to send invitation' }, { status: 500 }); + } + + // Create a placeholder profile for the invited user + if (newUser?.user) { + await adminClient.from('profiles').insert({ + id: newUser.user.id, + tenant_id: tenantId, + email, role: role, + }); + + await adminClient.from('tenant_members').insert({ tenant_id: tenantId, - invited_by: userId, - }, - }); + profile_id: newUser.user.id, + role: role, + }); + } return NextResponse.json({ success: true }); } catch (error) { + if (error instanceof Error && error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } console.error('Invite error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/apps/web/src/app/api/profiles/route.ts b/apps/web/src/app/api/profiles/route.ts index 872b19598..143c82cce 100644 --- a/apps/web/src/app/api/profiles/route.ts +++ b/apps/web/src/app/api/profiles/route.ts @@ -1,24 +1,27 @@ import { NextRequest, NextResponse } from 'next/server'; -import { sql } from '@/lib/neon/client'; +import { getTenantId } from '@/lib/supabase/getTenantId'; -export async function GET(request: NextRequest) { +export async function GET(_request: NextRequest) { try { - const { searchParams } = new URL(request.url); - const tenantId = searchParams.get('tenantId'); + const { tenantId, client } = await getTenantId(); - if (!tenantId) { - return NextResponse.json({ error: 'tenantId required' }, { status: 400 }); - } + const { data: profiles, error } = await client + .from('profiles') + .select('*') + .eq('tenant_id', tenantId) + .is('deleted_at', null); - const profiles = await sql` - SELECT * FROM profiles - WHERE tenant_id = ${tenantId} - AND deleted_at IS NULL - `; + if (error) { + console.error('Failed to fetch profiles:', error); + return NextResponse.json({ error: 'Failed to fetch profiles' }, { status: 500 }); + } - return NextResponse.json({ profiles }); + return NextResponse.json({ profiles: profiles ?? [] }); } catch (error) { + if (error instanceof Error && error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } console.error('Failed to fetch profiles:', error); return NextResponse.json({ error: 'Failed to fetch profiles' }, { status: 500 }); } -} +} \ No newline at end of file diff --git a/apps/web/src/app/api/roster/route.ts b/apps/web/src/app/api/roster/route.ts index 8400891c4..849752c24 100644 --- a/apps/web/src/app/api/roster/route.ts +++ b/apps/web/src/app/api/roster/route.ts @@ -1,229 +1,275 @@ import { NextRequest, NextResponse } from 'next/server'; -import { sql } from '@/lib/neon/client'; +import { getTenantId } from '@/lib/supabase/getTenantId'; +// --------------------------------------------------------------------------- +// GET — fetch roster + shifts for a tenant/week +// --------------------------------------------------------------------------- export async function GET(request: NextRequest) { try { + const { tenantId, client } = await getTenantId(); const { searchParams } = new URL(request.url); - const tenantId = searchParams.get('tenantId'); const weekStart = searchParams.get('weekStart'); - if (!tenantId || !weekStart) { - return NextResponse.json({ error: 'tenantId and weekStart required' }, { status: 400 }); + if (!weekStart) { + return NextResponse.json({ error: 'weekStart required' }, { status: 400 }); } - const rosters = await sql` - SELECT * FROM rosters - WHERE tenant_id = ${tenantId} - AND week_start = ${weekStart} - AND deleted_at IS NULL - ORDER BY created_at DESC - LIMIT 1 - `; + // Find or create roster + const { data: rosters } = await client + .from('rosters') + .select('*') + .eq('tenant_id', tenantId) + .eq('week_start', weekStart) + .is('deleted_at', null) + .order('created_at', { ascending: false }) + .limit(1); - let roster = rosters.length > 0 ? rosters[0] : null; + let roster = rosters && rosters.length > 0 ? rosters[0] : null; if (!roster) { - const locations = await sql` - SELECT id FROM locations - WHERE tenant_id = ${tenantId} - AND deleted_at IS NULL - LIMIT 1 - `; - - if (locations.length === 0) { + const { data: locations } = await client + .from('locations') + .select('id') + .eq('tenant_id', tenantId) + .is('deleted_at', null) + .limit(1); + + if (!locations || locations.length === 0) { return NextResponse.json({ error: 'No location found for tenant' }, { status: 404 }); } const locationId = locations[0].id; - const newRosters = await sql` - INSERT INTO rosters (tenant_id, location_id, week_start, status) - VALUES (${tenantId}, ${locationId}, ${weekStart}, 'draft') - RETURNING * - `; + const { data: newRosters } = await client + .from('rosters') + .insert({ tenant_id: tenantId, location_id: locationId, week_start: weekStart, status: 'draft' }) + .select('*'); - roster = newRosters.length > 0 ? newRosters[0] : null; + roster = newRosters && newRosters.length > 0 ? newRosters[0] : null; } if (!roster) { return NextResponse.json({ error: 'Failed to create roster' }, { status: 500 }); } - const shifts = await sql` - SELECT * FROM shifts - WHERE roster_id = ${roster.id} - AND deleted_at IS NULL - `; + const { data: shifts } = await client + .from('shifts') + .select('*') + .eq('roster_id', roster.id) + .is('deleted_at', null); - return NextResponse.json({ roster, shifts }); + return NextResponse.json({ roster, shifts: shifts ?? [] }); } catch (error) { + if (error instanceof Error && error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } console.error('Failed to fetch roster:', error); return NextResponse.json({ error: 'Failed to fetch roster' }, { status: 500 }); } } +// --------------------------------------------------------------------------- +// POST — publish / unpublish / copy-forward / save-shifts / update-shift / create-shift +// --------------------------------------------------------------------------- export async function POST(request: NextRequest) { try { + const { tenantId, client } = await getTenantId(); const body = await request.json(); - const { action, tenantId, weekStart, rosterId, shifts } = body; + const { action, weekStart, rosterId, shifts } = body; switch (action) { + // -- publish ---------------------------------------------------------- case 'publish': { - await sql` - UPDATE rosters - SET status = 'published', - published_at = ${new Date().toISOString()}, - published_by = 'system' - WHERE id = ${rosterId} - `; + await client + .from('rosters') + .update({ + status: 'published', + published_at: new Date().toISOString(), + published_by: 'system', + }) + .eq('id', rosterId); return NextResponse.json({ success: true }); } + // -- unpublish -------------------------------------------------------- case 'unpublish': { - await sql` - UPDATE rosters - SET status = 'draft', - published_at = NULL, - published_by = NULL - WHERE id = ${rosterId} - `; + await client + .from('rosters') + .update({ status: 'draft', published_at: null, published_by: null }) + .eq('id', rosterId); return NextResponse.json({ success: true }); } + // -- copy-forward ----------------------------------------------------- case 'copy-forward': { const currentWeekStart = new Date(weekStart); const newWeekStart = new Date(currentWeekStart); newWeekStart.setDate(currentWeekStart.getDate() + 7); const newWeekStartStr = newWeekStart.toISOString().split('T')[0]; - const existingRosters = await sql` - SELECT * FROM rosters - WHERE tenant_id = ${tenantId} - AND week_start = ${newWeekStartStr} - AND deleted_at IS NULL - `; + const { data: existingRosters } = await client + .from('rosters') + .select('*') + .eq('tenant_id', tenantId) + .eq('week_start', newWeekStartStr) + .is('deleted_at', null); - if (existingRosters.length > 0) { + if (existingRosters && existingRosters.length > 0) { return NextResponse.json({ success: true, roster: existingRosters[0] }); } - const roster = await sql`SELECT * FROM rosters WHERE id = ${rosterId}`; - if (roster.length === 0) { + const { data: sourceRoster } = await client + .from('rosters') + .select('*') + .eq('id', rosterId) + .single(); + + if (!sourceRoster) { return NextResponse.json({ error: 'Roster not found' }, { status: 404 }); } - const newRosters = await sql` - INSERT INTO rosters (tenant_id, location_id, week_start, status) - VALUES (${tenantId}, ${roster[0].location_id}, ${newWeekStartStr}, 'draft') - RETURNING * - `; - - if (newRosters.length === 0) { + const { data: newRosters } = await client + .from('rosters') + .insert({ + tenant_id: tenantId, + location_id: sourceRoster.location_id, + week_start: newWeekStartStr, + status: 'draft', + }) + .select('*'); + + if (!newRosters || newRosters.length === 0) { return NextResponse.json({ error: 'Failed to create new roster' }, { status: 500 }); } const newRoster = newRosters[0]; - const sourceShifts = await sql` - SELECT * FROM shifts - WHERE roster_id = ${rosterId} - AND deleted_at IS NULL - `; - - for (const shift of sourceShifts) { - const startTime = new Date(shift.start_time); - const endTime = new Date(shift.end_time); - startTime.setDate(startTime.getDate() + 7); - endTime.setDate(endTime.getDate() + 7); - - await sql` - INSERT INTO shifts (tenant_id, location_id, roster_id, profile_id, start_time, end_time, role_label, notes) - VALUES ( - ${shift.tenant_id}, - ${roster[0].location_id}, - ${newRoster.id}, - ${shift.profile_id}, - ${startTime.toISOString()}, - ${endTime.toISOString()}, - ${shift.role_label}, - ${shift.notes} - ) - `; + const { data: sourceShifts } = await client + .from('shifts') + .select('*') + .eq('roster_id', rosterId) + .is('deleted_at', null); + + if (sourceShifts) { + for (const shift of sourceShifts) { + const startTime = new Date(shift.start_time); + const endTime = new Date(shift.end_time); + startTime.setDate(startTime.getDate() + 7); + endTime.setDate(endTime.getDate() + 7); + + await client.from('shifts').insert({ + tenant_id: shift.tenant_id, + location_id: sourceRoster.location_id, + roster_id: newRoster.id, + profile_id: shift.profile_id, + start_time: startTime.toISOString(), + end_time: endTime.toISOString(), + role_label: shift.role_label, + notes: shift.notes, + }); + } } return NextResponse.json({ success: true, roster: newRoster }); } + // -- save-shifts ------------------------------------------------------ case 'save-shifts': { if (!rosterId || !shifts) { return NextResponse.json({ error: 'rosterId and shifts required' }, { status: 400 }); } - const roster = await sql`SELECT location_id FROM rosters WHERE id = ${rosterId}`; - if (roster.length === 0) { + const { data: roster } = await client + .from('rosters') + .select('location_id') + .eq('id', rosterId) + .single(); + + if (!roster) { return NextResponse.json({ error: 'Roster not found' }, { status: 404 }); } - const locationId = roster[0].location_id; - - await sql`DELETE FROM shifts WHERE roster_id = ${rosterId}`; + const locationId = roster.location_id; + + // Soft-delete: mark removed shifts, upsert current + const currentShiftIds = shifts + .filter((s: Record) => s.id) + .map((s: Record) => s.id); + + const softDeleteQuery = client + .from('shifts') + .update({ deleted_at: new Date().toISOString() }) + .eq('roster_id', rosterId); + + if (currentShiftIds.length > 0) { + await softDeleteQuery.not('id', 'in', `(${currentShiftIds.join(',')})`); + } else { + await softDeleteQuery; + } for (const shift of shifts) { - await sql` - INSERT INTO shifts (tenant_id, location_id, roster_id, profile_id, start_time, end_time, role_label, notes) - VALUES ( - ${tenantId}, - ${locationId}, - ${rosterId}, - ${shift.profile_id}, - ${shift.start_time}, - ${shift.end_time}, - ${shift.role_label || null}, - ${shift.notes || null} - ) - `; + await client.from('shifts').upsert( + { + id: shift.id || undefined, + tenant_id: tenantId, + location_id: locationId, + roster_id: rosterId, + profile_id: shift.profile_id, + start_time: shift.start_time, + end_time: shift.end_time, + role_label: shift.role_label || null, + notes: shift.notes || null, + deleted_at: null, + updated_at: new Date().toISOString(), + }, + { onConflict: 'id' }, + ); } return NextResponse.json({ success: true }); } + // -- update-shift ----------------------------------------------------- case 'update-shift': { const { shiftId, profileId, startTime, endTime } = body; - await sql` - UPDATE shifts - SET - profile_id = ${profileId}, - start_time = ${startTime}, - end_time = ${endTime} - WHERE id = ${shiftId} - `; + await client + .from('shifts') + .update({ + profile_id: profileId, + start_time: startTime, + end_time: endTime, + updated_at: new Date().toISOString(), + }) + .eq('id', shiftId); return NextResponse.json({ success: true }); } + // -- create-shift ----------------------------------------------------- case 'create-shift': { const { profileId, startTime, endTime, roleLabel, notes } = body; let locationId: string | null = null; if (rosterId) { - const roster = await sql`SELECT location_id FROM rosters WHERE id = ${rosterId}`; - if (roster.length > 0) { - locationId = roster[0].location_id; - } + const { data: r } = await client + .from('rosters') + .select('location_id') + .eq('id', rosterId) + .single(); + if (r) locationId = r.location_id; } - const newShifts = await sql` - INSERT INTO shifts (tenant_id, location_id, roster_id, profile_id, start_time, end_time, role_label, notes) - VALUES ( - ${tenantId}, - ${locationId}, - ${rosterId || null}, - ${profileId}, - ${startTime}, - ${endTime}, - ${roleLabel || null}, - ${notes || null} - ) - RETURNING * - `; - - if (newShifts.length === 0) { + const { data: newShifts } = await client + .from('shifts') + .insert({ + tenant_id: tenantId, + location_id: locationId, + roster_id: rosterId || null, + profile_id: profileId, + start_time: startTime, + end_time: endTime, + role_label: roleLabel || null, + notes: notes || null, + }) + .select('*'); + + if (!newShifts || newShifts.length === 0) { return NextResponse.json({ error: 'Failed to create shift' }, { status: 500 }); } @@ -234,7 +280,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Unknown action' }, { status: 400 }); } } catch (error) { + if (error instanceof Error && error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } console.error('Roster API error:', error); return NextResponse.json({ error: 'Failed to process roster request' }, { status: 500 }); } -} +} \ No newline at end of file diff --git a/apps/web/src/app/api/timesheets/approve/route.ts b/apps/web/src/app/api/timesheets/approve/route.ts index 0c612c9c2..6af80449c 100644 --- a/apps/web/src/app/api/timesheets/approve/route.ts +++ b/apps/web/src/app/api/timesheets/approve/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { auth } from '@clerk/nextjs/server'; -import { sql } from '@/lib/neon/client'; +import { requireAuth } from '@/lib/supabase/server'; export async function POST(request: NextRequest) { try { + const { user, client } = await requireAuth(); const body = await request.json(); const { profileId, workDate, tenantId } = body; @@ -11,26 +11,31 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } - let userId = 'demo-user'; + // Resolve userId — demo mode or real auth + let userId = user.id; const isDemoMode = tenantId?.startsWith('demo-'); - if (!isDemoMode) { - const { userId: clerkUserId } = await auth(); - if (!clerkUserId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - userId = clerkUserId; + if (isDemoMode) { + userId = 'demo-user'; } - await sql` - UPDATE clock_events - SET approved_at = ${new Date().toISOString()}, - approved_by = ${userId} - WHERE profile_id = ${profileId} - AND DATE(recorded_at AT TIME ZONE 'Australia/Melbourne') = ${workDate} - AND type = 'clock_in' - AND approved_at IS NULL - AND deleted_at IS NULL - `; + // Update clock_events for the given profile + date + const { error } = await client + .from('clock_events') + .update({ + approved_at: new Date().toISOString(), + approved_by: userId, + }) + .eq('profile_id', profileId) + .eq('type', 'clock_in') + .is('approved_at', null) + .is('deleted_at', null) + .gte('recorded_at', `${workDate}T00:00:00.000Z`) + .lt('recorded_at', `${workDate}T23:59:59.999Z`); + + if (error) { + console.error('Approve error:', error); + return NextResponse.json({ error: 'Failed to approve timesheets' }, { status: 500 }); + } return NextResponse.json({ success: true }); } catch (error) { diff --git a/apps/web/src/app/api/timesheets/route.ts b/apps/web/src/app/api/timesheets/route.ts index a8f17f91b..9c8e858c6 100644 --- a/apps/web/src/app/api/timesheets/route.ts +++ b/apps/web/src/app/api/timesheets/route.ts @@ -1,64 +1,128 @@ import { NextRequest, NextResponse } from 'next/server'; -import { sql } from '@/lib/neon/client'; +import { getTenantId } from '@/lib/supabase/getTenantId'; export async function GET(request: NextRequest) { try { + const { tenantId, client } = await getTenantId(); + const { searchParams } = new URL(request.url); - const tenantId = searchParams.get('tenantId'); const start = searchParams.get('start'); const end = searchParams.get('end'); - if (!tenantId || !start || !end) { - return NextResponse.json({ error: 'tenantId, start, and end required' }, { status: 400 }); + if (!start || !end) { + return NextResponse.json({ error: 'start and end required' }, { status: 400 }); + } + + // Fetch profiles + clock events for the tenant/date range + const { data: profiles } = await client + .from('profiles') + .select('id, first_name, last_name, email') + .eq('tenant_id', tenantId) + .is('deleted_at', null); + + if (!profiles || profiles.length === 0) { + return NextResponse.json({ entries: [] }); + } + + const profileIds = profiles.map((p) => p.id); + + const { data: events } = await client + .from('clock_events') + .select('profile_id, type, recorded_at, is_within_geofence, approved_at, approved_by, location_id') + .in('profile_id', profileIds) + .gte('recorded_at', start) + .lt('recorded_at', end) + .is('deleted_at', null) + .order('recorded_at', { ascending: true }); + + if (!events || events.length === 0) { + return NextResponse.json({ entries: [] }); } - const data = await sql` - WITH paired_events AS ( - SELECT - ce.profile_id, - p.first_name, - p.last_name, - p.email, - DATE(ce.recorded_at AT TIME ZONE 'Australia/Melbourne') as work_date, - MIN(CASE WHEN ce.type = 'clock_in' THEN ce.recorded_at END) as clock_in, - MAX(CASE WHEN ce.type = 'clock_out' THEN ce.recorded_at END) as clock_out, - MAX(ce.is_within_geofence) as is_within_geofence, - MAX(ce.approved_at) as approved_at, - MAX(ce.approved_by) as approved_by, - MAX(l.name) as location_name - FROM clock_events ce - JOIN profiles p ON p.id = ce.profile_id - LEFT JOIN locations l ON l.id = ce.location_id - WHERE p.tenant_id = ${tenantId} - AND ce.deleted_at IS NULL - AND ce.recorded_at >= ${start} - AND ce.recorded_at < ${end} - GROUP BY ce.profile_id, p.first_name, p.last_name, p.email, work_date - ) - SELECT - profile_id, - first_name, - last_name, - email, - work_date, - clock_in, - clock_out, - CASE - WHEN clock_in IS NOT NULL AND clock_out IS NOT NULL - THEN ROUND(EXTRACT(EPOCH FROM (clock_out - clock_in)) / 3600, 2) - ELSE NULL - END as total_hours, - is_within_geofence, - approved_at, - approved_by, - location_name - FROM paired_events - ORDER BY last_name, first_name, work_date - `; - - return NextResponse.json({ entries: data }); + // Group clock events by profile_id + work_date + const grouped: Record = {}; + + for (const ev of events) { + const workDate = new Date(ev.recorded_at).toISOString().split('T')[0]; + const key = `${ev.profile_id}_${workDate}`; + + if (!grouped[key]) { + grouped[key] = { + profile_id: ev.profile_id, + clock_in: null, + clock_out: null, + is_within_geofence: false, + approved_at: null, + approved_by: null, + location_id: null, + }; + } + + if (ev.type === 'clock_in') { + if (!grouped[key].clock_in || ev.recorded_at < grouped[key].clock_in!) { + grouped[key].clock_in = ev.recorded_at; + } + } else if (ev.type === 'clock_out') { + if (!grouped[key].clock_out || ev.recorded_at > grouped[key].clock_out!) { + grouped[key].clock_out = ev.recorded_at; + } + } + + if (ev.is_within_geofence) grouped[key].is_within_geofence = true; + if (ev.approved_at) grouped[key].approved_at = ev.approved_at; + if (ev.approved_by) grouped[key].approved_by = ev.approved_by; + if (ev.location_id) grouped[key].location_id = ev.location_id; + } + + // Build response entries + const profileMap = new Map(profiles.map((p) => [p.id, p])); + + const entries = Object.values(grouped).map((g) => { + const profile = profileMap.get(g.profile_id); + const totalHours = + g.clock_in && g.clock_out + ? Math.round(((new Date(g.clock_out).getTime() - new Date(g.clock_in).getTime()) / 3600000) * 100) / 100 + : null; + + return { + profile_id: g.profile_id, + first_name: profile?.first_name ?? '', + last_name: profile?.last_name ?? '', + email: profile?.email ?? '', + work_date: null, + clock_in: g.clock_in, + clock_out: g.clock_out, + total_hours: totalHours, + is_within_geofence: g.is_within_geofence, + approved_at: g.approved_at, + approved_by: g.approved_by, + location_name: null, + }; + }).map((e) => ({ + ...e, + work_date: e.clock_in ? new Date(e.clock_in).toISOString().split('T')[0] : null, + })); + + entries.sort((a, b) => { + const nameA = `${a.last_name} ${a.first_name}`; + const nameB = `${b.last_name} ${b.first_name}`; + return nameA.localeCompare(nameB) || (a.work_date ?? '').localeCompare(b.work_date ?? ''); + }); + + return NextResponse.json({ entries }); } catch (error) { + if (error instanceof Error && error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } console.error('Error fetching timesheet entries:', error); return NextResponse.json({ error: 'Failed to fetch timesheets' }, { status: 500 }); } -} +} \ No newline at end of file diff --git a/apps/web/src/app/api/user/profile/route.ts b/apps/web/src/app/api/user/profile/route.ts index 1411516f4..bc3220952 100644 --- a/apps/web/src/app/api/user/profile/route.ts +++ b/apps/web/src/app/api/user/profile/route.ts @@ -1,37 +1,31 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { auth } from '@clerk/nextjs/server'; -import { sql } from '@/lib/neon/client'; +import { NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/supabase/server'; -export async function GET(request: NextRequest) { +export async function GET() { try { - const { userId } = await auth(); - const { searchParams } = new URL(request.url); - const targetUserId = searchParams.get('userId'); + const { user, client } = await requireAuth(); - const queryUserId = targetUserId || userId; + const { data: profile, error } = await client + .from('profiles') + .select('tenant_id, role') + .eq('id', user.id) + .single(); - if (!queryUserId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const profiles = await sql` - SELECT tenant_id, role - FROM profiles - WHERE id = ${queryUserId} - `; - - if (profiles.length === 0) { - return NextResponse.json({ - tenantId: null, - role: null + if (error || !profile) { + return NextResponse.json({ + tenantId: null, + role: null, }); } return NextResponse.json({ - tenantId: profiles[0].tenant_id, - role: profiles[0].role, + tenantId: profile.tenant_id, + role: profile.role, }); } catch (error) { + if (error instanceof Error && error.message === 'Unauthorized') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } console.error('Error fetching user profile:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/apps/web/src/app/api/webhooks/stripe/route.ts b/apps/web/src/app/api/webhooks/stripe/route.ts index c710386d4..ba526922a 100644 --- a/apps/web/src/app/api/webhooks/stripe/route.ts +++ b/apps/web/src/app/api/webhooks/stripe/route.ts @@ -1,16 +1,16 @@ -import { NextRequest, NextResponse } from "next/server"; -import Stripe from "stripe"; -import { sql } from "@/lib/neon/client"; +import { NextRequest, NextResponse } from 'next/server'; +import { createAdminClient } from '@/lib/supabase/admin'; +import Stripe from 'stripe'; const getStripe = () => { if ( !process.env.STRIPE_SECRET_KEY || - process.env.STRIPE_SECRET_KEY === "sk_test_placeholder" + process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder' ) { return null; } return new Stripe(process.env.STRIPE_SECRET_KEY, { - apiVersion: "2025-02-24.acacia" as any, + apiVersion: '2025-02-24.acacia' as any, }); }; @@ -18,17 +18,17 @@ export async function POST(request: NextRequest) { const stripe = getStripe(); if (!stripe) { return NextResponse.json( - { error: "Stripe not configured" }, + { error: 'Stripe not configured' }, { status: 503 }, ); } const body = await request.text(); - const signature = request.headers.get("stripe-signature"); + const signature = request.headers.get('stripe-signature'); if (!signature) { return NextResponse.json( - { error: "Missing stripe-signature header" }, + { error: 'Missing stripe-signature header' }, { status: 400 }, ); } @@ -42,35 +42,38 @@ export async function POST(request: NextRequest) { process.env.STRIPE_WEBHOOK_SECRET!, ); } catch (err) { - console.error("Webhook signature verification failed:", err); - return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + console.error('Webhook signature verification failed:', err); + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); } + // Use admin client for webhook operations (called by Stripe, no user session) + const supabase = createAdminClient(); + switch (event.type) { - case "checkout.session.completed": { + case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session; - if (session.mode === "subscription" && session.subscription) { + if (session.mode === 'subscription' && session.subscription) { const subscription = await stripe.subscriptions.retrieve( session.subscription as string, ); const tenantId = session.metadata?.tenantId; if (tenantId) { - await sql` - UPDATE tenants - SET - stripe_customer_id = ${session.customer as string}, - stripe_subscription_id = ${session.subscription as string}, - plan = 'starter' - WHERE id = ${tenantId} - `; + await supabase + .from('tenants') + .update({ + stripe_customer_id: session.customer as string, + stripe_subscription_id: session.subscription as string, + plan: 'starter', + }) + .eq('id', tenantId); } } break; } - case "invoice.paid": { + case 'invoice.paid': { const invoice = event.data.object as Stripe.Invoice & { subscription?: string; }; @@ -82,45 +85,43 @@ export async function POST(request: NextRequest) { const tenantId = subscription.metadata?.tenantId; if (tenantId) { - await sql` - UPDATE tenants - SET plan = 'starter' - WHERE stripe_subscription_id = ${subscriptionId} - `; + await supabase + .from('tenants') + .update({ plan: 'starter' }) + .eq('stripe_subscription_id', subscriptionId); } } break; } - case "invoice.payment_failed": { + case 'invoice.payment_failed': { console.log(`Payment failed for invoice ${event.data.object}`); break; } - case "customer.subscription.updated": { + case 'customer.subscription.updated': { const subscription = event.data.object as Stripe.Subscription; const tenantId = subscription.metadata?.tenantId; - if (tenantId && subscription.status === "active") { - await sql` - UPDATE tenants - SET plan = 'starter' - WHERE stripe_subscription_id = ${subscription.id} - `; + if (tenantId && subscription.status === 'active') { + await supabase + .from('tenants') + .update({ plan: 'starter' }) + .eq('stripe_subscription_id', subscription.id); } break; } - case "customer.subscription.deleted": { + case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription; - await sql` - UPDATE tenants - SET - plan = 'free', - stripe_subscription_id = NULL - WHERE stripe_subscription_id = ${subscription.id} - `; + await supabase + .from('tenants') + .update({ + plan: 'free', + stripe_subscription_id: null, + }) + .eq('stripe_subscription_id', subscription.id); break; } diff --git a/apps/web/src/app/demo/page.tsx b/apps/web/src/app/demo/page.tsx index 586be445e..088b2ec14 100644 --- a/apps/web/src/app/demo/page.tsx +++ b/apps/web/src/app/demo/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; +import { createClient } from '@/lib/supabase/client'; import Logo from '@/components/Logo'; const DEMO_PERSONAS = [ @@ -129,27 +130,33 @@ export default function DemoPage() { setIsLoggingIn(email); try { - const response = await fetch('/api/demo/login', { + // First, notify the demo login API to create/ensure the Supabase user + await fetch('/api/demo/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, role: _role, tenantId: currentTenantId }), }); - const data = await response.json(); + // Sign in with Supabase Auth using the demo credentials + const supabase = createClient(); + const { error: signInError } = await supabase.auth.signInWithPassword({ + email, + password: 'crewcircle-demo-2026', + }); - if (data.success && data.token) { - const params = new URLSearchParams({ - token: data.token, - email: encodeURIComponent(email), - role: encodeURIComponent(_role), - tenantId: currentTenantId, - }); - router.push(`/demo-login?${params.toString()}`); - } else { - setError(data.error || 'Failed to sign in. Please try again.'); + if (signInError) { + setError('Failed to sign in: ' + signInError.message); setIsLoggingIn(null); + return; } - } catch (err) { + + // Store demo context in sessionStorage for useAuth hook + sessionStorage.setItem('demo_mode', 'true'); + sessionStorage.setItem('demo_tenantId', currentTenantId); + sessionStorage.setItem('demo_role', _role); + + router.push('/roster'); + } catch { setError('Failed to sign in. Please try again.'); setIsLoggingIn(null); } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c0cf7f7c0..aaa2ccbbd 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; -import { ClerkProvider } from "@clerk/nextjs"; import "./globals.css"; const geistSans = Geist({ @@ -81,13 +80,11 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - {children} - - + + {children} + ); } diff --git a/apps/web/src/app/settings/billing/page.tsx b/apps/web/src/app/settings/billing/page.tsx index 23abea528..f2b48dd16 100644 --- a/apps/web/src/app/settings/billing/page.tsx +++ b/apps/web/src/app/settings/billing/page.tsx @@ -1,8 +1,8 @@ "use client"; import React, { useState, useEffect } from 'react'; -import { useAuth } from '@/lib/clerk/useAuth'; -import { sql } from '@/lib/neon/client'; +import { useAuth } from '@/hooks/useAuth'; +import { createClient } from '@/lib/supabase/client'; interface Tenant { id: string; @@ -27,20 +27,24 @@ export default function BillingPage() { setIsLoading(true); try { - const tenantData = await sql` - SELECT id, name, plan, abn FROM tenants WHERE id = ${tenantId} - `; - - if (tenantData.length > 0) { + const supabase = createClient(); + + const { data: tenantData } = await supabase + .from('tenants') + .select('id, name, plan, abn') + .eq('id', tenantId); + + if (tenantData && tenantData.length > 0) { setTenant(tenantData[0] as Tenant); } - const count = await sql` - SELECT COUNT(*) as count FROM profiles - WHERE tenant_id = ${tenantId} AND deleted_at IS NULL - `; - - setEmployeeCount(Number(count[0]?.count) || 0); + const { count } = await supabase + .from('profiles') + .select('*', { count: 'exact', head: true }) + .eq('tenant_id', tenantId) + .is('deleted_at', null); + + setEmployeeCount(count || 0); } catch (error) { console.error('Error fetching billing data:', error); } finally { @@ -81,9 +85,11 @@ export default function BillingPage() { setAbnError(''); - await sql` - UPDATE tenants SET abn = ${abnInput.replace(/\s/g, '')} WHERE id = ${tenant!.id} - `; + const supabase = createClient(); + await supabase + .from('tenants') + .update({ abn: abnInput.replace(/\s/g, '') }) + .eq('id', tenant!.id); setTenant({ ...tenant!, abn: abnInput }); setShowAbnModal(false); @@ -97,7 +103,7 @@ export default function BillingPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tenantId: tenant!.id, - email: user?.emailAddresses?.[0]?.emailAddress, + email: user?.email, }), }); const { url } = await response.json(); diff --git a/apps/web/src/app/update-password/page.tsx b/apps/web/src/app/update-password/page.tsx index 7e9b364d0..1495c1a21 100644 --- a/apps/web/src/app/update-password/page.tsx +++ b/apps/web/src/app/update-password/page.tsx @@ -2,11 +2,9 @@ import { useState } from 'react'; import Link from 'next/link'; -import { useUser } from '@clerk/nextjs'; import { updatePasswordSchema } from '@/lib/validators/auth'; export default function UpdatePasswordPage() { - const { isLoaded } = useUser(); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(null); diff --git a/apps/web/src/components/LoginForm.tsx b/apps/web/src/components/LoginForm.tsx index fa5cd2106..bb348895f 100644 --- a/apps/web/src/components/LoginForm.tsx +++ b/apps/web/src/components/LoginForm.tsx @@ -1,21 +1,104 @@ 'use client'; -import { SignIn } from '@clerk/nextjs'; -import { useEffect } from 'react'; +import { useState, useEffect } from 'react'; +import { createClient } from '@/lib/supabase/client'; export default function LoginForm() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + useEffect(() => { const demoEmail = sessionStorage.getItem('demo_email'); const demoPassword = sessionStorage.getItem('demo_password'); if (demoEmail && demoPassword) { + setEmail(demoEmail); + setPassword(demoPassword); sessionStorage.removeItem('demo_email'); sessionStorage.removeItem('demo_password'); } }, []); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + try { + const supabase = createClient(); + const { error: signInError } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (signInError) { + setError(signInError.message); + return; + } + + window.location.href = '/roster'; + } catch (err) { + setError('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + return ( -
- +
+
+

Welcome back

+

Sign in to manage your roster

+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+ + setEmail(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 outline-none" + placeholder="you@example.com" + disabled={isLoading} + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 outline-none" + placeholder="Enter password" + disabled={isLoading} + required + /> +
+ + +
); } diff --git a/apps/web/src/components/RoleProtection.tsx b/apps/web/src/components/RoleProtection.tsx index 4cffc86f6..b777dd8f3 100644 --- a/apps/web/src/components/RoleProtection.tsx +++ b/apps/web/src/components/RoleProtection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useAuth } from '@/lib/clerk/useAuth'; +import { useAuth } from '@/hooks/useAuth'; import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; diff --git a/apps/web/src/components/SignupForm.tsx b/apps/web/src/components/SignupForm.tsx index d91d489ad..244a1e3b4 100644 --- a/apps/web/src/components/SignupForm.tsx +++ b/apps/web/src/components/SignupForm.tsx @@ -1,11 +1,116 @@ 'use client'; -import { SignUp } from '@clerk/nextjs'; +import { useState } from 'react'; +import { createClient } from '@/lib/supabase/client'; export default function SignupForm() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [businessName, setBusinessName] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + try { + const supabase = createClient(); + const { error: signUpError } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + business_name: businessName || email.split('@')[0], + full_name: email.split('@')[0], + }, + emailRedirectTo: `${window.location.origin}/roster`, + }, + }); + + if (signUpError) { + setError(signUpError.message); + return; + } + + window.location.href = '/roster'; + } catch (err) { + setError('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + return ( -
- +
+
+

Create your account

+

Start managing your team in minutes

+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+ + setBusinessName(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 outline-none" + placeholder="Your Cafe" + disabled={isLoading} + /> +
+ +
+ + setEmail(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 outline-none" + placeholder="you@example.com" + disabled={isLoading} + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 outline-none" + placeholder="At least 8 characters" + disabled={isLoading} + required + /> +
+ + +
); } diff --git a/apps/web/src/config/demo.ts b/apps/web/src/config/demo.ts index 87195082b..220796730 100644 --- a/apps/web/src/config/demo.ts +++ b/apps/web/src/config/demo.ts @@ -8,7 +8,7 @@ export const demoConfig = { // Demo tenant ID - pre-created in database via migration tenantId: process.env.NEXT_PUBLIC_DEMO_TENANT_ID || '4fdcd51f-04bc-4f72-8909-3bc0f75934f1', - // Demo users - must exist in Clerk AND have profiles in database + // Demo users - must exist in Supabase Auth AND have profiles in database users: [ { email: 'demo-owner@crewcircle.co', diff --git a/apps/web/src/features/roster/RosterGrid.tsx b/apps/web/src/features/roster/RosterGrid.tsx index d05b06bb5..94381c66b 100644 --- a/apps/web/src/features/roster/RosterGrid.tsx +++ b/apps/web/src/features/roster/RosterGrid.tsx @@ -1,48 +1,33 @@ -"use client"; - -import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; -import { - DndContext, - closestCenter, - PointerSensor, - useSensor, +'use client'; + +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, KeyboardSensor, - defaultCoordinates, DragOverlay, type DragStartEvent, type DragOverEvent, type DragEndEvent, } from '@dnd-kit/core'; -import { - SortableContext, - verticalListSortingStrategy, - arrayMove -} from '@dnd-kit/sortable'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useRosterStore } from '@/store/rosterStore'; -import { Shift } from '@/types/shift'; -import { Profile } from '@/types/profile'; -import { Roster } from '@/store/rosterStore'; -import { Availability } from '@/lib/validators/conflicts'; - -interface ShiftFormData { - employeeId: string; - startTime: string; - endTime: string; - roleLabel: string; - notes: string; -} -import { useAuth } from '@/lib/clerk/useAuth'; +import type { Shift } from '@/types/shift'; +import type { Profile } from '@/types/profile'; +import type { Roster } from '@/store/rosterStore'; +import { Availability, detectConflicts } from '@packages/validators'; +import { useAuth } from '@/hooks/useAuth'; import { z } from 'zod'; import { shiftSchema } from '@/lib/validators/shift'; -import { detectConflicts } from '@/lib/validators/conflicts'; import { format } from 'date-fns'; import { useRosterRealtime } from './hooks/useRosterRealtime'; +import ShiftCreationModal from './ShiftCreationModal'; +import ShiftItem from './ShiftItem'; +import RosterHeader, { DAYS_OF_WEEK } from './RosterHeader'; -// Constants -const DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - -// Zod schema for shift creation (basic validation) +// Zod schema for shift creation const shiftCreationSchema = z.object({ employeeId: z.string(), startTime: z.string().refine((val) => !isNaN(Date.parse(val)), 'Invalid start time'), @@ -51,191 +36,55 @@ const shiftCreationSchema = z.object({ notes: z.string().optional(), }); -// Shift creation modal component -const ShiftCreationModal: React.FC<{ - open: boolean; - onClose: () => void; - onSave: (shiftData: z.infer) => void; - employees: Profile[]; -}> = ({ open, onClose, onSave, employees }) => { - const [formData, setFormData] = useState({ - employeeId: '', - startTime: '', - endTime: '', - roleLabel: '', - notes: '', - }); - const [errors, setErrors] = useState<{ [key: string]: string } | null>(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev: ShiftFormData) => ({ ...prev, [name]: value })); - if (errors && errors[name]) { - setErrors((prev: { [key: string]: string } | null) => { - const newErrors = { ...prev }; - delete newErrors[name]; - return newErrors; - }); - } - }; - - 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: { [key: string]: string } = {}; - err.issues.forEach((issue) => { - if (issue.path.length > 0) { - errorMap[issue.path[0] as string] = issue.message; - } - }); - setErrors(errorMap); - } else { - console.error('Unexpected error:', err); - } - } finally { - setIsSubmitting(false); - } - }; - - if (!open) return null; +// Helper: get day-of-week index from timestamp in Sydney timezone +function getDayFromTimestamp(timestamp: string): number { + const date = new Date(timestamp); + const formatter = new Intl.DateTimeFormat('en-US', { timeZone: 'Australia/Sydney', weekday: 'short' }); + const dayName = formatter.format(date); + return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(dayName); +} - return ( -
-
-

Add Shift

-
-
- - - {errors?.employeeId &&

{errors.employeeId}

} -
-
- - - {errors?.startTime &&

{errors.startTime}

} -
-
- - - {errors?.endTime &&

{errors.endTime}

} -
-
- - -
-
- -