Files
data-entry-app/frontend/src/routes/settings/+page.svelte
T

637 lines
18 KiB
Svelte
Raw Normal View History

2026-04-27 21:53:36 +12:00
<script lang="ts">
2026-05-08 09:06:14 +12:00
import { api } from '$lib/api';
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
2026-04-27 21:53:36 +12:00
import { clientSession } from '$lib/session';
import { canEditThroughput } from '$lib/workspace-access';
2026-05-08 09:06:14 +12:00
import { toast } from '$lib/toast';
import type { ThroughputImportResult } from '$lib/types';
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte';
2026-04-27 21:53:36 +12:00
type Section = 'profile' | 'security' | 'import';
2026-05-08 09:06:14 +12:00
let activeSection = $state<Section>('profile');
2026-04-27 21:53:36 +12:00
// Only operators who can edit throughput see (and can use) the import tool.
const canImportThroughput = $derived(canEditThroughput($clientSession));
2026-05-08 09:06:14 +12:00
let name = $state($clientSession?.name ?? '');
let email = $state($clientSession?.email ?? '');
2026-04-27 21:53:36 +12:00
2026-05-08 09:06:14 +12:00
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
2026-04-27 21:53:36 +12:00
2026-05-08 09:06:14 +12:00
let profileSaving = $state(false);
let passwordSaving = $state(false);
let passwordError = $state('');
async function saveProfile() {
profileSaving = true;
const tid = toast.loading('Saving profile…');
try {
const updated = await api.updateMe({ name: name.trim(), email: email.trim().toLowerCase() });
clientSession.set({
...$clientSession!,
name: updated.name,
email: updated.email,
});
toast.dismiss(tid);
toast.success('Profile updated');
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'An error occurred');
} finally {
profileSaving = false;
}
2026-04-27 21:53:36 +12:00
}
2026-05-08 09:06:14 +12:00
async function savePassword() {
passwordError = '';
if (newPassword !== confirmPassword) {
passwordError = 'New passwords do not match';
return;
}
if (newPassword.length < 8) {
passwordError = 'Password must be at least 8 characters';
return;
}
passwordSaving = true;
const tid = toast.loading('Updating password…');
try {
await api.updateMe({ current_password: currentPassword, new_password: newPassword });
currentPassword = '';
newPassword = '';
confirmPassword = '';
toast.dismiss(tid);
toast.success('Password updated');
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'An error occurred';
passwordError = msg;
toast.error(msg);
} finally {
passwordSaving = false;
}
}
// ── Throughput import ─────────────────────────────────────────
let importFile = $state<File | null>(null);
let importing = $state(false);
let importResult = $state<ThroughputImportResult | null>(null);
let importError = $state('');
function onImportFileChange(event: Event) {
const input = event.currentTarget as HTMLInputElement;
importFile = input.files?.[0] ?? null;
importResult = null;
importError = '';
}
async function runImport() {
if (!importFile) {
importError = 'Choose a CSV or spreadsheet file first.';
return;
}
importing = true;
importError = '';
importResult = null;
const tid = toast.loading('Importing entries…');
try {
const result = await api.importThroughputEntries(importFile);
importResult = result;
toast.dismiss(tid);
if (result.entries_imported > 0) {
toast.success(
`Imported ${result.entries_imported} ${result.entries_imported === 1 ? 'entry' : 'entries'}`
);
} else {
toast.error('No entries were imported. Check the file and try again.');
}
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'Import failed';
importError = msg;
toast.error(msg);
} finally {
importing = false;
}
}
// Build a small sample CSV in the browser so operators have a working header
// row to copy from. The backend matches these headers case-insensitively.
function downloadTemplate() {
const headers = [
'Date',
'Product',
'Item ID',
'Quantity',
'Type',
'Bag Size',
'Packed By',
'For Order',
'Job Number',
'For Stock',
'Stock Quantity',
'Notes'
];
const example = [
'2026-06-12',
'Specialty Pigeon Breeder',
'',
'40',
'bags',
'20',
'Jane Doe',
'yes',
'JOB1234',
'no',
'',
'First run of the day'
];
const csv = `${headers.join(',')}\n${example.join(',')}\n`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'throughput-import-template.csv';
link.click();
URL.revokeObjectURL(url);
}
2026-05-08 09:06:14 +12:00
const initials = $derived(
($clientSession?.name ?? '')
.split(' ')
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase() || '?'
);
const navItems = $derived<{ id: Section; label: string; icon: typeof CircleUserRound }[]>([
2026-05-08 09:06:14 +12:00
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
{ id: 'security', label: 'Security', icon: LockKeyhole },
...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
]);
const railGroups = $derived([{ items: navItems }]);
2026-05-08 09:06:14 +12:00
</script>
<AppSecondaryRailLayout>
{#snippet rail()}
<AppSecondaryRail
sectionLabel="Settings"
identityAvatarText={initials}
identityTitle={$clientSession?.name ?? 'Unknown'}
identitySubtitle={$clientSession?.role_name ?? $clientSession?.role ?? 'User'}
groups={railGroups}
activeId={activeSection}
onSelect={(id) => (activeSection = id as Section)}
/>
{/snippet}
2026-05-08 09:06:14 +12:00
<div class="settings-panel">
{#if activeSection === 'profile'}
<div class="panel-section">
<header class="panel-header">
<h2>Profile</h2>
<p>Update your display name and email address.</p>
</header>
<form class="panel-form" onsubmit={(e) => { e.preventDefault(); saveProfile(); }}>
<div class="field-row">
<div class="field">
<label for="name">Full name</label>
<input id="name" type="text" bind:value={name} autocomplete="name" required />
</div>
<div class="field">
<label for="email">Email address</label>
<input id="email" type="email" bind:value={email} autocomplete="email" required />
</div>
</div>
<div class="form-footer">
<button class="btn-primary" type="submit" disabled={profileSaving}>
{profileSaving ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
</div>
{:else if activeSection === 'security'}
<div class="panel-section">
<header class="panel-header">
<h2>Security</h2>
<p>Choose a strong password with at least 8 characters.</p>
</header>
<form class="panel-form" onsubmit={(e) => { e.preventDefault(); savePassword(); }}>
<div class="field">
<label for="current-password">Current password</label>
<input id="current-password" type="password" bind:value={currentPassword} autocomplete="current-password" required />
</div>
<div class="divider" aria-hidden="true"></div>
<div class="field-row">
<div class="field">
<label for="new-password">New password</label>
<input id="new-password" type="password" bind:value={newPassword} autocomplete="new-password" required />
</div>
<div class="field">
<label for="confirm-password">Confirm new password</label>
<input id="confirm-password" type="password" bind:value={confirmPassword} autocomplete="new-password" required />
</div>
</div>
{#if passwordError}
<p class="form-error">{passwordError}</p>
{/if}
<div class="form-footer">
<button class="btn-primary" type="submit" disabled={passwordSaving}>
{passwordSaving ? 'Updating…' : 'Update password'}
</button>
</div>
</form>
</div>
{:else if activeSection === 'import' && canImportThroughput}
<div class="panel-section">
<header class="panel-header">
<h2>Import throughput entries</h2>
<p>Upload a CSV or Excel (.xlsx) file of packing runs. Each row is saved as a throughput entry.</p>
</header>
<div class="import-body">
<div class="import-help">
<h3>Required columns</h3>
<p>
Your file needs a header row with at least <strong>Date</strong>, <strong>Product</strong>
and <strong>Quantity</strong> columns. These optional columns are also recognised:
</p>
<ul>
<li><strong>Type</strong><code>bags</code> or <code>kg</code> (inferred from bag size if omitted)</li>
<li><strong>Bag Size</strong> — kg per bag (required when packing as bags)</li>
<li><strong>Item ID</strong> — matches an existing product; otherwise matched by name</li>
<li><strong>Packed By</strong>, <strong>Notes</strong></li>
<li><strong>For Order</strong>, <strong>Job Number</strong>, <strong>For Stock</strong>, <strong>Stock Quantity</strong></li>
</ul>
<p class="import-note">
Products that don't already exist are created automatically. Dates accept
<code>YYYY-MM-DD</code> or <code>DD/MM/YYYY</code>.
</p>
<button type="button" class="btn-link" onclick={downloadTemplate}>
<FileSpreadsheet size={15} strokeWidth={2.2} /> Download CSV template
</button>
</div>
<div class="import-control">
<label class="file-drop" class:has-file={!!importFile}>
<input
type="file"
accept=".csv,.xlsx,.xlsm,.xls,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onchange={onImportFileChange}
/>
<Upload size={22} strokeWidth={2} />
<span class="file-drop-label">
{importFile ? importFile.name : 'Choose a CSV or .xlsx file'}
</span>
{#if importFile}
<span class="file-drop-size">{(importFile.size / 1024).toFixed(1)} KB</span>
{/if}
</label>
{#if importError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {importError}</p>
{/if}
{#if importResult}
<div class="import-result" role="status">
<p class="import-result-head">
Imported <strong>{importResult.entries_imported}</strong>
{importResult.entries_imported === 1 ? 'entry' : 'entries'}.
</p>
<ul class="import-result-stats">
{#if importResult.products_created > 0}
<li>{importResult.products_created} new product{importResult.products_created === 1 ? '' : 's'} created</li>
{/if}
{#if importResult.entries_skipped > 0}
<li>{importResult.entries_skipped} row{importResult.entries_skipped === 1 ? '' : 's'} skipped</li>
{/if}
</ul>
{#if importResult.errors.length > 0}
<details class="import-errors">
<summary>{importResult.errors.length} issue{importResult.errors.length === 1 ? '' : 's'} to review</summary>
<ul>
{#each importResult.errors as err (err)}
<li>{err}</li>
{/each}
</ul>
</details>
{/if}
</div>
{/if}
<div class="form-footer">
<button class="btn-primary" type="button" disabled={importing || !importFile} onclick={runImport}>
{importing ? 'Importing…' : 'Import entries'}
</button>
</div>
</div>
</div>
</div>
2026-05-08 09:06:14 +12:00
{/if}
</div>
</AppSecondaryRailLayout>
2026-05-08 09:06:14 +12:00
<style>
.settings-panel {
display: flex;
flex-direction: column;
min-width: 0;
background: var(--panel);
}
.panel-section {
display: flex;
flex-direction: column;
}
.panel-header {
padding: 1.5rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.panel-header h2 {
margin: 0 0 0.3rem;
font-size: 1.1rem;
2026-04-27 21:53:36 +12:00
font-weight: 700;
}
2026-05-08 09:06:14 +12:00
.panel-header p {
margin: 0;
font-size: 0.85rem;
2026-04-27 21:53:36 +12:00
color: var(--muted);
}
2026-05-08 09:06:14 +12:00
/* ── Form ───────────────────────────────────────────────────── */
.panel-form {
display: grid;
gap: 1rem;
width: 100%;
padding: 1.5rem 1.75rem;
max-width: 42rem;
}
.field-row {
2026-04-27 21:53:36 +12:00
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
2026-05-08 09:06:14 +12:00
.field {
2026-04-27 21:53:36 +12:00
display: grid;
2026-05-08 09:06:14 +12:00
gap: 0.42rem;
}
.field label {
font-size: 0.82rem;
font-weight: 600;
color: var(--text);
}
.field input {
width: 100%;
padding: 0.62rem 0.85rem;
2026-04-27 21:53:36 +12:00
border: 1px solid var(--line);
2026-05-08 09:06:14 +12:00
border-radius: 0.6rem;
2026-04-27 21:53:36 +12:00
background: var(--panel-soft);
2026-05-08 09:06:14 +12:00
color: var(--text);
font-size: 0.9rem;
transition: border-color 140ms ease, box-shadow 140ms ease;
box-sizing: border-box;
2026-04-27 21:53:36 +12:00
}
2026-05-08 09:06:14 +12:00
.field input:focus {
outline: none;
border-color: var(--green-deep);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
.divider {
height: 1px;
background: var(--line);
margin: 0.25rem 0;
}
.form-error {
margin: 0;
padding: 0.65rem 0.85rem;
background: color-mix(in srgb, #e53e3e 8%, transparent);
border: 1px solid color-mix(in srgb, #e53e3e 25%, transparent);
border-radius: 0.6rem;
color: #c53030;
2026-04-27 21:53:36 +12:00
font-size: 0.84rem;
}
2026-05-08 09:06:14 +12:00
.form-footer {
display: flex;
justify-content: flex-end;
padding-top: 0.25rem;
border-top: 1px solid var(--line);
margin-top: 0.5rem;
2026-04-27 21:53:36 +12:00
}
2026-05-08 09:06:14 +12:00
.btn-primary {
padding: 0.58rem 1.4rem;
background: var(--color-brand);
color: #fff;
border: none;
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: opacity 140ms ease;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.88;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Import ─────────────────────────────────────────────────── */
.import-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1.5rem;
padding: 1.5rem 1.75rem;
}
.import-help h3 {
margin: 0 0 0.5rem;
font-size: 0.92rem;
font-weight: 700;
color: var(--text);
}
.import-help p {
margin: 0 0 0.65rem;
font-size: 0.85rem;
line-height: 1.5;
color: var(--muted);
}
.import-help ul {
margin: 0 0 0.65rem;
padding-left: 1.1rem;
display: grid;
gap: 0.3rem;
font-size: 0.84rem;
color: var(--muted);
}
.import-help code {
padding: 0.05rem 0.32rem;
border-radius: 0.35rem;
background: var(--panel-soft);
border: 1px solid var(--line);
font-size: 0.8rem;
}
.import-note {
font-size: 0.82rem;
}
.btn-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0;
background: none;
border: none;
color: var(--color-brand);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.btn-link:hover {
text-decoration: underline;
}
.import-control {
display: grid;
gap: 1rem;
align-content: start;
}
.file-drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem 1.25rem;
border: 1.5px dashed var(--line);
border-radius: 0.75rem;
background: var(--panel-soft);
color: var(--muted);
cursor: pointer;
text-align: center;
transition: border-color 140ms ease, color 140ms ease;
}
.file-drop:hover {
border-color: var(--color-brand);
color: var(--text);
}
.file-drop.has-file {
border-style: solid;
border-color: var(--color-brand);
color: var(--text);
}
.file-drop input {
display: none;
}
.file-drop-label {
font-size: 0.88rem;
font-weight: 600;
word-break: break-all;
}
.file-drop-size {
font-size: 0.78rem;
color: var(--muted);
}
.form-error {
display: flex;
align-items: center;
gap: 0.4rem;
}
.import-result {
padding: 0.85rem 1rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 25%, transparent);
border-radius: 0.6rem;
background: color-mix(in srgb, var(--color-brand) 7%, transparent);
}
.import-result-head {
margin: 0;
font-size: 0.9rem;
color: var(--text);
}
.import-result-stats {
margin: 0.45rem 0 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
font-size: 0.83rem;
color: var(--muted);
}
.import-errors {
margin-top: 0.6rem;
font-size: 0.83rem;
color: var(--muted);
}
.import-errors summary {
cursor: pointer;
font-weight: 600;
color: var(--text);
}
.import-errors ul {
margin: 0.45rem 0 0;
padding-left: 1.1rem;
display: grid;
gap: 0.25rem;
max-height: 12rem;
overflow-y: auto;
}
2026-05-08 09:06:14 +12:00
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 720px) {
.field-row {
2026-04-27 21:53:36 +12:00
grid-template-columns: 1fr;
}
.import-body {
grid-template-columns: 1fr;
}
2026-04-27 21:53:36 +12:00
}
</style>