Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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;
1 change: 1 addition & 0 deletions src/lib/server/sheets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Comment on lines 42 to 48

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

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

appendToSheet only writes the header row when the sheet is empty. After adding new columns ('Staff ID', 'Exported At', 'Export ID'), existing spreadsheets will start receiving extra columns but their header row will remain the old version (no labels), which undermines the audit trail UX. Consider detecting an existing header row and updating/patching it when it’s missing these columns (or documenting that users must clear/update the header manually).

Copilot uses AI. Check for mistakes.

/**
Expand Down
23 changes: 20 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,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,
Expand All @@ -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 => [
Expand All @@ -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 {
Expand All @@ -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 });
};
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 };
},
};
85 changes: 80 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,65 @@

<!-- 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 {
await update();
}
};
Comment on lines +133 to +158

Copilot AI Apr 9, 2026

Copy link

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 +192,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
78 changes: 66 additions & 12 deletions src/routes/calculate/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}`;
}
Comment on lines 33 to +40

Copilot AI Apr 9, 2026

Copy link

Choose a reason for hiding this comment

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

On successful export you synthesize a new exportLog entry using Date.now() and set exported_by: null, even though the server generates/stores exported_at (unixepoch) and exported_by (locals.user.id). This can make the UI's “Last exported …” display diverge from the actual audit log (clock skew, wrong user). Prefer using server-returned values (e.g., have the endpoint return the inserted export_log row with exported_at/exported_by), or re-fetch/exportLog after success.

Copilot uses AI. Check for mistakes.
} catch {
exportMsg = 'Export failed — check Sheets config in Settings';
}
Expand Down Expand Up @@ -62,7 +82,10 @@
<div class="card">
<p class="label">FOH — ${formatCents(c.foh_pool_cents)} ÷ {fohDists.length + barDists.length}</p>
{#each fohDists as d}
<div class="row"><span>{d.name}</span><span class="money amt">${formatCents(d.total_cents)}</span></div>
<div class="row">
<span>{d.name}{#if d.staff_id}<span class="staff-id">#{d.staff_id}</span>{/if}</span>
<span class="money amt">${formatCents(d.total_cents)}</span>
</div>
{/each}
</div>
{/if}
Expand All @@ -71,7 +94,8 @@
<div class="card">
<p class="label">Bar</p>
{#each barDists as d}
<div class="row"><span>{d.name}</span>
<div class="row">
<span>{d.name}{#if d.staff_id}<span class="staff-id">#{d.staff_id}</span>{/if}</span>
<div style="text-align:right;">
<div class="money amt">${formatCents(d.total_cents)}</div>
<div style="font-size:0.75rem;color:var(--muted);">
Expand All @@ -87,7 +111,10 @@
<div class="card">
<p class="label">Kitchen — ${formatCents(c.kitchen_pool_cents)} ÷ {kitDists.length}</p>
{#each kitDists as d}
<div class="row"><span>{d.name}</span><span class="money amt">${formatCents(d.total_cents)}</span></div>
<div class="row">
<span>{d.name}{#if d.staff_id}<span class="staff-id">#{d.staff_id}</span>{/if}</span>
<span class="money amt">${formatCents(d.total_cents)}</span>
</div>
{/each}
</div>
{/if}
Expand All @@ -100,14 +127,35 @@
</div>
{:else}
<a href="/calculate/{c.id}/share" class="btn btn-primary">Share Card</a>
<button class="btn btn-secondary" onclick={exportToSheets} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export to Google Sheets'}
</button>
{#if exportMsg}
<p class:success-msg={exportMsg.startsWith('✓')} class:error-msg={!exportMsg.startsWith('✓')}>
{exportMsg}
</p>
{/if}

<div>
<button class="btn btn-secondary" onclick={exportToSheets} disabled={exporting} style="width:100%;">
{#if exporting}
Exporting…
{:else if lastExport}
Export Again to Google Sheets
{:else}
Export to Google Sheets
{/if}
</button>
{#if lastExport && !exportMsg}
<p style="font-size:0.75rem;color:var(--muted);text-align:center;margin-top:0.35rem;">
Last exported {formatExportTime(lastExport.exported_at)} · Export #{lastExport.id}
</p>
{/if}
{#if exportLog.length > 1}
<p style="font-size:0.75rem;color:var(--muted);text-align:center;margin-top:0.2rem;">
{exportLog.length} total exports
</p>
{/if}
{#if exportMsg}
<p class:success-msg={!exportMsg.startsWith('Error')} class:error-msg={exportMsg.startsWith('Error')}
style="text-align:center;margin-top:0.35rem;">
{exportMsg}
</p>
{/if}
</div>

<form method="POST" action="?/void">
<button type="submit" class="btn btn-danger"
onclick={e => { if (!confirm('Void this calculation? A VOID row will be added to Google Sheets.')) e.preventDefault(); }}>
Expand All @@ -133,4 +181,10 @@
.row.muted { color: var(--muted); }
.row.total { font-weight: 700; color: var(--text); }
.amt { font-size: 1.1rem; font-weight: 600; color: var(--primary); }
.staff-id {
font-size: 0.7rem;
color: var(--muted);
margin-left: 0.3rem;
font-weight: 400;
}
</style>
Loading