From 8b06bd3c50b13fb029782dbb9e5dfb5c85c3c4cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 01:22:18 +0000 Subject: [PATCH 1/2] Add audit log, duplicate prevention, quick-add staff, role editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export_log table tracks every Google Sheets export (id, calc_id, exported_at, exported_by) - Export endpoint logs each export and returns Export ID + timestamp - Sheets rows now include Staff ID, Exported At, Export ID columns for full audit trail - Re-exporting a calculation shows a confirmation dialog with the previous export timestamp - calculate/[id] page shows last export time and Export # below the button; warns on re-export - Quick-add staff form on /calculate screen (available to shift leads AND managers) - New person is auto-selected for the current shift after add - Staff list updates reactively without a full page reload - Duplicate-name disambiguation: shows #ID badge when two staff share the same name - Visible on /calculate staff list and /settings/staff roster - Staff role can now be changed inline from Settings → Staff Roster (changeRole action) - No longer requires remove + re-add to change FOH ↔ Bar ↔ Kitchen https://claude.ai/code/session_01Sa6KBxSzsg6gWDavRF9dke --- src/lib/server/db.ts | 16 +++++ src/lib/server/sheets.ts | 1 + src/routes/api/export/+server.ts | 23 +++++- src/routes/calculate/+page.server.ts | 17 +++++ src/routes/calculate/+page.svelte | 85 +++++++++++++++++++++-- src/routes/calculate/[id]/+page.server.ts | 8 ++- src/routes/calculate/[id]/+page.svelte | 78 +++++++++++++++++---- src/routes/settings/staff/+page.server.ts | 16 +++++ src/routes/settings/staff/+page.svelte | 62 ++++++++++++----- 9 files changed, 267 insertions(+), 39 deletions(-) diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 830c126..c3ba069 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -82,6 +82,14 @@ db.exec(` user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL -- ISO8601 e.g. '2026-04-06T22:00:00.000Z' ); + + CREATE TABLE IF NOT EXISTS export_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + calculation_id INTEGER NOT NULL REFERENCES tip_calculations(id) ON DELETE CASCADE, + exported_at INTEGER NOT NULL DEFAULT (unixepoch()), + exported_by INTEGER REFERENCES users(id), + location_id INTEGER NOT NULL DEFAULT 1 CHECK (location_id = 1) + ); `); // Migrations for columns added after initial schema @@ -172,4 +180,12 @@ export type DistRow = { total_cents: number; }; +export type ExportLogRow = { + id: number; + calculation_id: number; + exported_at: number; + exported_by: number | null; + location_id: number; +}; + export default db; diff --git a/src/lib/server/sheets.ts b/src/lib/server/sheets.ts index 26532dd..cf40d8e 100644 --- a/src/lib/server/sheets.ts +++ b/src/lib/server/sheets.ts @@ -29,6 +29,7 @@ export const HEADER_ROW = [ 'Gross Tips', 'CC Fee Rate', 'CC Fees', 'Net Tips', 'Kitchen %', 'Kitchen Pool', 'Liquor Sales', 'Bar Liquor %', 'Bar Pool', 'FOH Pool', 'Name', 'Role', 'FOH Share', 'Bar Share', 'Kitchen Share', 'Total', + 'Staff ID', 'Exported At', 'Export ID', ]; /** diff --git a/src/routes/api/export/+server.ts b/src/routes/api/export/+server.ts index 9ed5c02..1cb9924 100644 --- a/src/routes/api/export/+server.ts +++ b/src/routes/api/export/+server.ts @@ -4,7 +4,7 @@ import db from '$lib/server/db'; import { getSettings } from '$lib/server/auth'; import { appendToSheet } from '$lib/server/sheets'; import { formatCents } from '$lib/calculator'; -import type { CalcRow, DistRow } from '$lib/server/db'; +import type { CalcRow, DistRow, ExportLogRow } from '$lib/server/db'; export const POST: RequestHandler = async ({ request, locals }) => { if (!locals.user) error(401, 'Unauthorized'); @@ -26,10 +26,18 @@ export const POST: RequestHandler = async ({ request, locals }) => { const credJson = process.env.GOOGLE_SERVICE_ACCOUNT_JSON; if (!credJson) error(400, 'GOOGLE_SERVICE_ACCOUNT_JSON env var not set.'); + // Create export log entry first to get the Export ID + const { lastInsertRowid: exportId } = db.prepare( + 'INSERT INTO export_log (calculation_id, exported_by) VALUES (?, ?)' + ).run(calculationId, locals.user.id); + + const exportedAt = new Date().toISOString(); + // Columns: Date, Shift, Type, Calc ID, // Gross Tips, CC Fee Rate, CC Fees, Net Tips, // Kitchen %, Kitchen Pool, Liquor Sales, Bar Liquor %, Bar Pool, FOH Pool, - // Name, Role, FOH Share, Bar Share, Kitchen Share, Total + // Name, Role, FOH Share, Bar Share, Kitchen Share, Total, + // Staff ID, Exported At, Export ID const summaryRow: (string | number)[] = [ calc.date, calc.shift, 'summary', calc.id, @@ -44,6 +52,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { formatCents(calc.bar_pool_cents), formatCents(calc.foh_pool_cents), '', '', '', '', '', '', + '', exportedAt, Number(exportId), ]; const staffRows: (string | number)[][] = dists.map(d => [ @@ -54,6 +63,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { formatCents(d.bar_pool_share_cents), formatCents(d.kitchen_share_cents), formatCents(d.total_cents), + d.staff_id ?? '', exportedAt, Number(exportId), ]); try { @@ -64,9 +74,16 @@ export const POST: RequestHandler = async ({ request, locals }) => { credJson ); } catch (e) { + // Roll back export log entry on failure + db.prepare('DELETE FROM export_log WHERE id = ?').run(exportId); const msg = e instanceof Error ? e.message : 'Unknown error'; error(500, `Sheets export failed: ${msg}`); } - return json({ success: true }); + // Return export info so the UI can update + const exportLog = db.prepare( + 'SELECT * FROM export_log WHERE calculation_id = ? ORDER BY exported_at DESC' + ).all(calculationId) as ExportLogRow[]; + + return json({ success: true, exportId: Number(exportId), exportedAt, exportCount: exportLog.length }); }; diff --git a/src/routes/calculate/+page.server.ts b/src/routes/calculate/+page.server.ts index bb24ade..d988ec0 100644 --- a/src/routes/calculate/+page.server.ts +++ b/src/routes/calculate/+page.server.ts @@ -98,4 +98,21 @@ export const actions: Actions = { redirect(303, `/calculate/${calcId}`); }, + + addStaff: async ({ request, locals }) => { + if (!locals.user) redirect(303, '/'); + + const fd = await request.formData(); + const name = String(fd.get('name') ?? '').trim(); + const role = String(fd.get('role') ?? ''); + + if (!name) return fail(400, { addError: 'Name is required' }); + if (!['FOH', 'Kitchen', 'Bar'].includes(role)) return fail(400, { addError: 'Invalid role' }); + + const { lastInsertRowid } = db.prepare( + 'INSERT INTO staff (name, role) VALUES (?, ?)' + ).run(name, role); + + return { addedId: Number(lastInsertRowid), addedName: name }; + }, }; diff --git a/src/routes/calculate/+page.svelte b/src/routes/calculate/+page.svelte index 9f29a55..f28e884 100644 --- a/src/routes/calculate/+page.svelte +++ b/src/routes/calculate/+page.svelte @@ -1,6 +1,7 @@ @@ -103,10 +119,65 @@
-

Staff Working This Shift

- {#if data.staff.length === 0} +
+

Staff Working This Shift

+ +
+ + {#if showAddForm} +
+
{ + if (!newName.trim()) { addError = 'Name is required'; cancel(); return; } + addingStaff = true; + addError = ''; + return async ({ result, update }) => { + addingStaff = false; + if (result.type === 'success' && result.data?.addedId) { + const newPerson: StaffRow = { + id: result.data.addedId as number, + name: newName.trim(), + role: newRole, + active: 1, + location_id: 1, + source: 'manual', + square_team_member_id: null, + }; + staff = [...staff, newPerson].sort((a, b) => a.role.localeCompare(b.role) || a.name.localeCompare(b.name)); + included = new Set([...included, newPerson.id]); + newName = ''; + showAddForm = false; + } else { + await update(); + } + }; + }}> +

New Staff Member

+
+ + +
+ {#if addError}

{addError}

{/if} + +
+
+ {/if} + + {#if staff.length === 0}

- No staff yet. Add staff in Settings. + No staff yet. Use "+ Add Person" above to add someone.

{:else} {#each ROLE_GROUPS as { label, role }} @@ -121,6 +192,10 @@ checked={checked} onchange={() => toggleStaff(person.id)} style="width:20px;height:20px;accent-color:var(--primary);cursor:pointer;" /> {person.name} + {#if nameCounts[person.name] > 1} + #{person.id} + {/if} {/each} {/if} diff --git a/src/routes/calculate/[id]/+page.server.ts b/src/routes/calculate/[id]/+page.server.ts index 9f00316..32a388c 100644 --- a/src/routes/calculate/[id]/+page.server.ts +++ b/src/routes/calculate/[id]/+page.server.ts @@ -1,7 +1,7 @@ import type { Actions, PageServerLoad } from './$types'; import { error, fail, redirect } from '@sveltejs/kit'; import db from '$lib/server/db'; -import type { CalcRow, DistRow } from '$lib/server/db'; +import type { CalcRow, DistRow, ExportLogRow } from '$lib/server/db'; import { getSettings } from '$lib/server/auth'; import { appendToSheet } from '$lib/server/sheets'; @@ -18,7 +18,11 @@ export const load: PageServerLoad = ({ locals, params }) => { 'SELECT * FROM tip_distributions WHERE calculation_id = ? ORDER BY role, name' ).all(params.id) as DistRow[]; - return { calc, distributions }; + const exportLog = db.prepare( + 'SELECT * FROM export_log WHERE calculation_id = ? ORDER BY exported_at DESC' + ).all(params.id) as ExportLogRow[]; + + return { calc, distributions, exportLog }; }; export const actions: Actions = { diff --git a/src/routes/calculate/[id]/+page.svelte b/src/routes/calculate/[id]/+page.svelte index 58f23f3..02cbb07 100644 --- a/src/routes/calculate/[id]/+page.svelte +++ b/src/routes/calculate/[id]/+page.svelte @@ -6,8 +6,22 @@ let exporting = $state(false); let exportMsg = $state(''); + let exportLog = $state(data.exportLog); + + const lastExport = $derived(exportLog.length > 0 ? exportLog[0] : null); + + function formatExportTime(unixSec: number): string { + return new Date(unixSec * 1000).toLocaleString(); + } async function exportToSheets() { + if (lastExport) { + const confirmed = confirm( + `This calculation was already exported on ${formatExportTime(lastExport.exported_at)}.\n\nExport again?` + ); + if (!confirmed) return; + } + exporting = true; exportMsg = ''; try { @@ -17,7 +31,13 @@ body: JSON.stringify({ calculationId: data.calc.id }), }); const json = await res.json(); - exportMsg = res.ok ? '✓ Exported to Google Sheets' : `Error: ${json.message}`; + if (res.ok) { + exportMsg = `Exported (Export #${json.exportId})`; + // Update local export log so button reflects new state without a page reload + exportLog = [{ id: json.exportId, calculation_id: data.calc.id, exported_at: Math.floor(Date.now() / 1000), exported_by: null, location_id: 1 }, ...exportLog]; + } else { + exportMsg = `Error: ${json.message}`; + } } catch { exportMsg = 'Export failed — check Sheets config in Settings'; } @@ -62,7 +82,10 @@

FOH — ${formatCents(c.foh_pool_cents)} ÷ {fohDists.length + barDists.length}

{#each fohDists as d} -
{d.name}${formatCents(d.total_cents)}
+
+ {d.name}{#if d.staff_id}#{d.staff_id}{/if} + ${formatCents(d.total_cents)} +
{/each}
{/if} @@ -71,7 +94,8 @@

Bar

{#each barDists as d} -
{d.name} +
+ {d.name}{#if d.staff_id}#{d.staff_id}{/if}
${formatCents(d.total_cents)}
@@ -87,7 +111,10 @@

Kitchen — ${formatCents(c.kitchen_pool_cents)} ÷ {kitDists.length}

{#each kitDists as d} -
{d.name}${formatCents(d.total_cents)}
+
+ {d.name}{#if d.staff_id}#{d.staff_id}{/if} + ${formatCents(d.total_cents)} +
{/each}
{/if} @@ -100,14 +127,35 @@
{:else} Share Card - - {#if exportMsg} -

- {exportMsg} -

- {/if} + +
+ + {#if lastExport && !exportMsg} +

+ Last exported {formatExportTime(lastExport.exported_at)} · Export #{lastExport.id} +

+ {/if} + {#if exportLog.length > 1} +

+ {exportLog.length} total exports +

+ {/if} + {#if exportMsg} +

+ {exportMsg} +

+ {/if} +
+
-
-
- - -
+
+
+
+ {person.name} + {#if nameCounts[person.name] > 1} + #{person.id} + {/if} +
+
+
+ + +
+
+ + +
+
+ +
+ + Role: + + +
{/each}
From 26b8c2271f76bef08076a0643431352269c2c510 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Apr 2026 02:05:04 +0000 Subject: [PATCH 2/2] Address Copilot review feedback - Quick-add form now surfaces server-side addError on failure instead of silently calling update(); failure path reads result.data.addError - Export UI uses server-returned exportedAtUnix/exportedBy values instead of Date.now()/null so the displayed timestamp matches the audit log record exactly - Sheets.ts now reads row 1 before appending; if the header exists but is missing the new audit columns it patches row 1 in-place via PUT before appending data - Role-change form in staff settings now renders form.roleError so failures are visible rather than silently dropped https://claude.ai/code/session_01Sa6KBxSzsg6gWDavRF9dke --- src/lib/server/sheets.ts | 43 +++++++++++++++++++++----- src/routes/api/export/+server.ts | 10 ++++-- src/routes/calculate/+page.svelte | 2 ++ src/routes/calculate/[id]/+page.svelte | 4 +-- src/routes/settings/staff/+page.svelte | 3 ++ 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/lib/server/sheets.ts b/src/lib/server/sheets.ts index cf40d8e..50bf506 100644 --- a/src/lib/server/sheets.ts +++ b/src/lib/server/sheets.ts @@ -12,16 +12,31 @@ async function getToken(credJson: string): Promise { return token; } -async function sheetIsEmpty( +async function getFirstRow( spreadsheetId: string, - range: string, + sheetName: string, token: string -): Promise { +): Promise { + const range = encodeURIComponent(`'${sheetName}'!1:1`); const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); - if (!res.ok) return true; + if (!res.ok) return null; const body = await res.json(); - return !body.values || body.values.length === 0; + return body.values?.[0] ?? null; +} + +async function patchHeaderRow( + spreadsheetId: string, + sheetName: string, + token: string +): Promise { + const range = encodeURIComponent(`'${sheetName}'!1:1`); + const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}?valueInputOption=USER_ENTERED`; + await fetch(url, { + method: 'PUT', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ values: [HEADER_ROW] }), + }); } export const HEADER_ROW = [ @@ -35,6 +50,7 @@ export const HEADER_ROW = [ /** * Append rows to a Google Sheet. * Writes the header row first only when the sheet is empty. + * Patches the header row if it exists but is missing new audit columns. */ export async function appendToSheet( spreadsheetId: string, @@ -46,8 +62,21 @@ export async function appendToSheet( const range = encodeURIComponent(`'${sheetName}'!A1`); const base = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}`; - const empty = await sheetIsEmpty(spreadsheetId, range, token); - const values = empty ? [HEADER_ROW, ...dataRows] : dataRows; + const firstRow = await getFirstRow(spreadsheetId, sheetName, token); + + let values: (string | number)[][]; + if (!firstRow || firstRow.length === 0) { + // Sheet is empty — prepend the header + values = [HEADER_ROW, ...dataRows]; + } else { + // Sheet has content — check if the header is up to date + const lastHeaderCol = HEADER_ROW[HEADER_ROW.length - 1]; + if (firstRow[firstRow.length - 1] !== lastHeaderCol) { + // Header exists but is missing new columns — patch row 1 in place + await patchHeaderRow(spreadsheetId, sheetName, token); + } + values = dataRows; + } const url = `${base}/values/${range}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`; const res = await fetch(url, { diff --git a/src/routes/api/export/+server.ts b/src/routes/api/export/+server.ts index 1cb9924..8683aee 100644 --- a/src/routes/api/export/+server.ts +++ b/src/routes/api/export/+server.ts @@ -26,12 +26,16 @@ export const POST: RequestHandler = async ({ request, locals }) => { const credJson = process.env.GOOGLE_SERVICE_ACCOUNT_JSON; if (!credJson) error(400, 'GOOGLE_SERVICE_ACCOUNT_JSON env var not set.'); - // Create export log entry first to get the Export ID + // Create export log entry first to get the Export ID and DB-authoritative timestamp const { lastInsertRowid: exportId } = db.prepare( 'INSERT INTO export_log (calculation_id, exported_by) VALUES (?, ?)' ).run(calculationId, locals.user.id); - const exportedAt = new Date().toISOString(); + const logRow = db.prepare( + 'SELECT * FROM export_log WHERE id = ?' + ).get(exportId) as ExportLogRow; + + const exportedAt = new Date(logRow.exported_at * 1000).toISOString(); // Columns: Date, Shift, Type, Calc ID, // Gross Tips, CC Fee Rate, CC Fees, Net Tips, @@ -85,5 +89,5 @@ export const POST: RequestHandler = async ({ request, locals }) => { 'SELECT * FROM export_log WHERE calculation_id = ? ORDER BY exported_at DESC' ).all(calculationId) as ExportLogRow[]; - return json({ success: true, exportId: Number(exportId), exportedAt, exportCount: exportLog.length }); + return json({ success: true, exportId: Number(exportId), exportedAt, exportedAtUnix: logRow.exported_at, exportedBy: locals.user.id, exportCount: exportLog.length }); }; diff --git a/src/routes/calculate/+page.svelte b/src/routes/calculate/+page.svelte index f28e884..c90011b 100644 --- a/src/routes/calculate/+page.svelte +++ b/src/routes/calculate/+page.svelte @@ -150,6 +150,8 @@ included = new Set([...included, newPerson.id]); newName = ''; showAddForm = false; + } else if (result.type === 'failure') { + addError = String(result.data?.addError ?? 'Failed to add staff member'); } else { await update(); } diff --git a/src/routes/calculate/[id]/+page.svelte b/src/routes/calculate/[id]/+page.svelte index 02cbb07..e3ee910 100644 --- a/src/routes/calculate/[id]/+page.svelte +++ b/src/routes/calculate/[id]/+page.svelte @@ -33,8 +33,8 @@ const json = await res.json(); if (res.ok) { exportMsg = `Exported (Export #${json.exportId})`; - // Update local export log so button reflects new state without a page reload - exportLog = [{ id: json.exportId, calculation_id: data.calc.id, exported_at: Math.floor(Date.now() / 1000), exported_by: null, location_id: 1 }, ...exportLog]; + // Use server-returned values to match the actual audit log record + exportLog = [{ id: json.exportId, calculation_id: data.calc.id, exported_at: json.exportedAtUnix, exported_by: json.exportedBy, location_id: 1 }, ...exportLog]; } else { exportMsg = `Error: ${json.message}`; } diff --git a/src/routes/settings/staff/+page.svelte b/src/routes/settings/staff/+page.svelte index 95c5fdf..e883a32 100644 --- a/src/routes/settings/staff/+page.svelte +++ b/src/routes/settings/staff/+page.svelte @@ -93,6 +93,9 @@ Change + {#if form && 'roleError' in form} +

{(form as {roleError: string}).roleError}

+ {/if}
{/each}