v0.1.19 - Throughput overview & responsive header
- Throughput: new "Throughput Overview" header with Gauge icon and brand-green badge icons on each card (Today, This week, 4-week average, Horse Mix, Grain Mix) - Throughput: inline rolling-range selector (7d / 4w / 6w / 12w, default 4 weeks) driving the customer-mix cards; stats window widened to 12 weeks so switching range is a pure client-side re-filter - Throughput: cards collapse to a single even 5-across row on laptop and up, with container-query value text that scales to each card's width - Throughput: date logic pinned to Australian Eastern time (fixes the day-early date); This week subtitle shows the Mon-Sun date range - Throughput: subtler tinted add-form; removed the inline-entry kicker and the "Open full form" link - Topbar: fix cramped laptop header - action toggles no longer wrap above the user button; search drops to its own row earlier Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,17 @@
|
||||
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
|
||||
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
||||
import { clientSession } from '$lib/session';
|
||||
import { canEditThroughput } from '$lib/workspace-access';
|
||||
import { toast } from '$lib/toast';
|
||||
import { CircleUserRound, LockKeyhole } from 'lucide-svelte';
|
||||
import type { ThroughputImportResult } from '$lib/types';
|
||||
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte';
|
||||
|
||||
type Section = 'profile' | 'security';
|
||||
type Section = 'profile' | 'security' | 'import';
|
||||
let activeSection = $state<Section>('profile');
|
||||
|
||||
// Only operators who can edit throughput see (and can use) the import tool.
|
||||
const canImportThroughput = $derived(canEditThroughput($clientSession));
|
||||
|
||||
let name = $state($clientSession?.name ?? '');
|
||||
let email = $state($clientSession?.email ?? '');
|
||||
|
||||
@@ -69,6 +74,90 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
const initials = $derived(
|
||||
($clientSession?.name ?? '')
|
||||
.split(' ')
|
||||
@@ -78,12 +167,13 @@
|
||||
.toUpperCase() || '?'
|
||||
);
|
||||
|
||||
const navItems: { id: Section; label: string; icon: typeof CircleUserRound }[] = [
|
||||
const navItems = $derived<{ id: Section; label: string; icon: typeof CircleUserRound }[]>([
|
||||
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
|
||||
{ id: 'security', label: 'Security', icon: LockKeyhole },
|
||||
];
|
||||
...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
|
||||
]);
|
||||
|
||||
const railGroups = [{ items: navItems }];
|
||||
const railGroups = $derived([{ items: navItems }]);
|
||||
</script>
|
||||
|
||||
<AppSecondaryRailLayout>
|
||||
@@ -164,6 +254,92 @@
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
</AppSecondaryRailLayout>
|
||||
@@ -288,11 +464,173 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.import-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user