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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/lib/server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
44 changes: 37 additions & 7 deletions src/lib/server/sheets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,45 @@ async function getToken(credJson: string): Promise<string> {
return token;
}

async function sheetIsEmpty(
async function getFirstRow(
spreadsheetId: string,
range: string,
sheetName: string,
token: string
): Promise<boolean> {
): Promise<string[] | null> {
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<void> {
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 = [
'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',
'Staff ID', 'Exported At', 'Export ID',
];

/**
* 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,
Expand All @@ -45,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, {
Expand Down
27 changes: 24 additions & 3 deletions src/routes/api/export/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -26,10 +26,22 @@ 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 and DB-authoritative timestamp
const { lastInsertRowid: exportId } = db.prepare(
'INSERT INTO export_log (calculation_id, exported_by) VALUES (?, ?)'
).run(calculationId, locals.user.id);

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,
// 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,
Expand All @@ -44,6 +56,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 => [
Expand All @@ -54,6 +67,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 {
Expand All @@ -64,9 +78,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, exportedAtUnix: logRow.exported_at, exportedBy: locals.user.id, exportCount: exportLog.length });
};
17 changes: 17 additions & 0 deletions src/routes/calculate/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
},
};
87 changes: 82 additions & 5 deletions src/routes/calculate/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
import type { StaffRow } from '$lib/server/db';

let { data, form }: { data: PageData; form: ActionData } = $props();

let loading = $state(false);
let shift = $state(data.defaultShift);
let date = $state(data.today);

// Live staff list — starts from server data, updated when new person is added
let staff = $state<StaffRow[]>(data.staff);

// Staff checked state — all active staff included by default
let included = $state<Set<number>>(new Set(data.staff.map(s => s.id)));

Expand All @@ -17,6 +21,18 @@
included = next;
}

// Quick-add staff
let showAddForm = $state(false);
let addingStaff = $state(false);
let newName = $state('');
let newRole = $state<'FOH' | 'Bar' | 'Kitchen'>('FOH');
let addError = $state('');

// Detect duplicate names to show ID badges
const nameCounts = $derived(
staff.reduce((acc, s) => { acc[s.name] = (acc[s.name] ?? 0) + 1; return acc; }, {} as Record<string, number>)
);

type RoleGroup = { label: string; role: 'FOH' | 'Bar' | 'Kitchen' };
const ROLE_GROUPS: RoleGroup[] = [
{ label: 'FOH', role: 'FOH' },
Expand All @@ -26,8 +42,8 @@

const staffByRole = $derived(
Object.fromEntries(
ROLE_GROUPS.map(g => [g.role, data.staff.filter(s => s.role === g.role)])
) as Record<'FOH' | 'Bar' | 'Kitchen', typeof data.staff>
ROLE_GROUPS.map(g => [g.role, staff.filter(s => s.role === g.role)])
) as Record<'FOH' | 'Bar' | 'Kitchen', StaffRow[]>
);
</script>

Expand Down Expand Up @@ -103,10 +119,67 @@

<!-- Staff -->
<div class="card">
<p class="label">Staff Working This Shift</p>
{#if data.staff.length === 0}
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
<p class="label" style="margin:0;">Staff Working This Shift</p>
<button type="button" onclick={() => { showAddForm = !showAddForm; addError = ''; }}
style="background:none;font-size:0.8rem;font-weight:600;color:var(--primary);padding:0.2rem 0.5rem;
border:1.5px solid var(--primary);border-radius:6px;">
{showAddForm ? 'Cancel' : '+ Add Person'}
</button>
</div>

{#if showAddForm}
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:0.75rem;margin-bottom:0.75rem;">
<form method="POST" action="?/addStaff" use:enhance={({ cancel }) => {
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 if (result.type === 'failure') {
addError = String(result.data?.addError ?? 'Failed to add staff member');
} else {
await update();
}
};
Comment on lines +133 to +158
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quick-add form never surfaces server-side failures: in the non-success path you only call update() and addError remains empty, so users get no feedback if the action returns fail(400, { addError: ... }) (or any other failure). Consider handling result.type === 'failure' by pulling addError from result.data (and keeping the form open), and only calling update() for success/navigation as needed.

Copilot uses AI. Check for mistakes.
}}>
<p style="font-size:0.75rem;font-weight:600;color:var(--muted);margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em;">New Staff Member</p>
<div style="display:grid;grid-template-columns:1fr auto;gap:0.5rem;margin-bottom:0.5rem;">
<input class="input" type="text" name="name" bind:value={newName}
placeholder="Full name" style="font-size:0.9rem;" />
<select class="input" name="role" bind:value={newRole}
style="width:auto;padding-right:1.5rem;font-size:0.9rem;">
<option value="FOH">FOH</option>
<option value="Bar">Bar</option>
<option value="Kitchen">Kitchen</option>
</select>
</div>
{#if addError}<p class="error-msg" style="margin-bottom:0.5rem;">{addError}</p>{/if}
<button type="submit" class="btn btn-primary" style="padding:0.5rem 1rem;font-size:0.875rem;"
disabled={addingStaff || !newName.trim()}>
{addingStaff ? 'Adding…' : 'Add & Include in This Shift'}
</button>
</form>
</div>
{/if}

{#if staff.length === 0}
<p style="color:var(--muted);font-size:0.875rem;">
No staff yet. <a href="/settings/staff">Add staff in Settings.</a>
No staff yet. Use "+ Add Person" above to add someone.
</p>
{:else}
{#each ROLE_GROUPS as { label, role }}
Expand All @@ -121,6 +194,10 @@
checked={checked} onchange={() => toggleStaff(person.id)}
style="width:20px;height:20px;accent-color:var(--primary);cursor:pointer;" />
<span style="font-size:1rem;">{person.name}</span>
{#if nameCounts[person.name] > 1}
<span style="font-size:0.7rem;color:var(--muted);background:var(--bg);
border:1px solid var(--border);border-radius:4px;padding:0.1rem 0.35rem;">#{person.id}</span>
{/if}
</label>
{/each}
{/if}
Expand Down
8 changes: 6 additions & 2 deletions src/routes/calculate/[id]/+page.server.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 = {
Expand Down
Loading