-
Notifications
You must be signed in to change notification settings - Fork 0
Add audit log, duplicate prevention, quick-add staff, role editing #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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))); | ||
|
|
||
|
|
@@ -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' }, | ||
|
|
@@ -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> | ||
|
|
||
|
|
@@ -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
|
||
| }}> | ||
| <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 }} | ||
|
|
@@ -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} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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}`; | ||
| } | ||
|
Comment on lines
33
to
+40
|
||
| } catch { | ||
| exportMsg = 'Export failed — check Sheets config in Settings'; | ||
| } | ||
|
|
@@ -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} | ||
|
|
@@ -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);"> | ||
|
|
@@ -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} | ||
|
|
@@ -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(); }}> | ||
|
|
@@ -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> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
appendToSheetonly 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).