diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d7073f9..a959030 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.22", + "version": "0.1.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.22", + "version": "0.1.23", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index e83bda2..7c18d7a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.22", + "version": "0.1.23", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte new file mode 100644 index 0000000..42e3e9f --- /dev/null +++ b/frontend/src/lib/components/AppShell.svelte @@ -0,0 +1,606 @@ + + + + {shellTitle} | Hunter Premium Produce + + +{#if !$clientSession} +
+ {#if isRootRoute} + {@render children()} + {:else if showWorkspaceBoot} + + {:else} + + {/if} +
+{:else} +
+ {#if !showBottomNav} + + {/if} + +
+ canUseWorkspaceSearch && openPalette()} + onToggleUserMenu={() => { + userMenuOpen = !userMenuOpen; + quickMenuOpen = false; + }} + onOpenSettings={openSettings} + onSignOut={signOut} + onShowWhatsNew={() => (whatsNewOpen = true)} + /> + +
+ + {@render children()} + +
+
+ + openPalette('')} + /> +
+ + openPalette('')} + onOpenSettings={openSettings} + onSignOut={signOut} +/> + +{/if} + +{#if $clientSession && whatsNewOpen && currentChangelog} + +{/if} + +{#if $clientSession && paletteOpen} + (paletteOpen = false)} + onRunSearchItem={runSearchItem} + /> +{/if} + + diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte deleted file mode 100644 index 87a1de3..0000000 --- a/frontend/src/lib/components/ClientShell.svelte +++ /dev/null @@ -1,1524 +0,0 @@ - - - - {shellTitle} | Hunter Premium Produce - - -{#if !$clientSession} -
- {#if isRootRoute} - {@render children()} - {:else} -
-

Checking Session

-

Returning to the client login screen.

-

Only authenticated client users can open workspace routes directly.

-
- {/if} -
-{:else} -
- {#if showBottomNav && navOpen} - - {/if} - - {#if !showBottomNav} - - {/if} - -
- canUseWorkspaceSearch && openPalette()} - onToggleUserMenu={() => { - userMenuOpen = !userMenuOpen; - quickMenuOpen = false; - }} - onOpenSettings={openSettings} - onSignOut={signOut} - onShowWhatsNew={() => (whatsNewOpen = true)} - /> - -
- - {@render children()} - -
-
- -
- {#if quickMenuOpen} - - {/if} - - {#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canUseWorkspaceSearch} - - {/if} -
-
- -{#if showBottomNav} - - - {#if navOpen} - - {/if} -{/if} - -{/if} - -{#if $clientSession && whatsNewOpen && currentChangelog} - -{/if} - -{#if $clientSession && paletteOpen} - -{/if} - - diff --git a/frontend/src/lib/components/app-shell/WorkspaceBootCard.svelte b/frontend/src/lib/components/app-shell/WorkspaceBootCard.svelte new file mode 100644 index 0000000..85aea37 --- /dev/null +++ b/frontend/src/lib/components/app-shell/WorkspaceBootCard.svelte @@ -0,0 +1,131 @@ + + +
+ +

Checking Workspace

+

Restoring your client workspace.

+

Hold on while we reload your saved session and bring the app back into place.

+ +
+ + diff --git a/frontend/src/lib/components/app-shell/WorkspaceQuickAccess.svelte b/frontend/src/lib/components/app-shell/WorkspaceQuickAccess.svelte new file mode 100644 index 0000000..8c19604 --- /dev/null +++ b/frontend/src/lib/components/app-shell/WorkspaceQuickAccess.svelte @@ -0,0 +1,161 @@ + + +{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canUseWorkspaceSearch} +
+ {#if quickMenuOpen} + + {/if} + + +
+{/if} + + diff --git a/frontend/src/lib/components/app-shell/WorkspaceSearchPalette.svelte b/frontend/src/lib/components/app-shell/WorkspaceSearchPalette.svelte new file mode 100644 index 0000000..80877f6 --- /dev/null +++ b/frontend/src/lib/components/app-shell/WorkspaceSearchPalette.svelte @@ -0,0 +1,201 @@ + + + + + diff --git a/frontend/src/lib/components/app-shell/WorkspaceSignedOutCard.svelte b/frontend/src/lib/components/app-shell/WorkspaceSignedOutCard.svelte new file mode 100644 index 0000000..c41ec23 --- /dev/null +++ b/frontend/src/lib/components/app-shell/WorkspaceSignedOutCard.svelte @@ -0,0 +1,47 @@ +
+

Checking Session

+

Returning to the client login screen.

+

Only authenticated client users can open workspace routes directly.

+
+ + diff --git a/frontend/src/lib/components/app-shell/WorkspaceTabletNav.svelte b/frontend/src/lib/components/app-shell/WorkspaceTabletNav.svelte new file mode 100644 index 0000000..3a13f00 --- /dev/null +++ b/frontend/src/lib/components/app-shell/WorkspaceTabletNav.svelte @@ -0,0 +1,520 @@ + + +{#if showBottomNav} + {#if navOpen} + + {/if} + + + + {#if navOpen} + + {/if} +{/if} + + diff --git a/frontend/src/lib/components/app-shell/utils.ts b/frontend/src/lib/components/app-shell/utils.ts new file mode 100644 index 0000000..86d1600 --- /dev/null +++ b/frontend/src/lib/components/app-shell/utils.ts @@ -0,0 +1,24 @@ +import type { SearchItem } from '$lib/navigation/client-navigation'; +import type { AppSession } from '$lib/session'; + +export const PALETTE_RESULT_LIMIT = 10; + +export function buildSessionKey(session: AppSession | null | undefined): string | null { + if (!session) return null; + return `${session.role}:${session.email}:${session.user_id ?? ''}`; +} + +export function filterSearchItems(items: SearchItem[], query: string, limit = PALETTE_RESULT_LIMIT) { + const trimmedQuery = query.trim().toLowerCase(); + const matchingItems = items.filter((item) => { + const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase(); + return haystack.includes(trimmedQuery); + }); + + const filteredItems = matchingItems.slice(0, limit); + return { + matchingItems, + filteredItems, + hiddenResultCount: matchingItems.length - filteredItems.length + }; +} diff --git a/frontend/src/lib/components/throughput/ThroughputComposer.svelte b/frontend/src/lib/components/throughput/ThroughputComposer.svelte new file mode 100644 index 0000000..947e556 --- /dev/null +++ b/frontend/src/lib/components/throughput/ThroughputComposer.svelte @@ -0,0 +1,538 @@ + + +
+
+
+

{editingId != null ? 'Edit packing run' : 'Add a packing run'}

+
+ {#if editingId != null} + + {/if} +
+ +
{ e.preventDefault(); onSubmit(); }}> +
+ Date + +
+
+ Product + +
+
+ Packed +
+ + + {#if nType === 'bags'} + + + {/if} +
+ {#if nType === 'bags' && addTotalKg !== null} + = {formatNumber(addTotalKg)} kg total + {/if} +
+
+ Packed by + +
+
+ Destination +
+
+ + {#if nForOrder} + + {/if} +
+
+ + {#if isSplit} + + {/if} +
+
+
+
+ +
+ +
+ {#if showNote} +
+ + +
+ {:else} + + {/if} + {#if addError} + {addError} + {/if} +
+
+
+ + diff --git a/frontend/src/lib/components/throughput/ThroughputDeleteDialog.svelte b/frontend/src/lib/components/throughput/ThroughputDeleteDialog.svelte new file mode 100644 index 0000000..ec8a0a6 --- /dev/null +++ b/frontend/src/lib/components/throughput/ThroughputDeleteDialog.svelte @@ -0,0 +1,172 @@ + + + + + diff --git a/frontend/src/lib/components/throughput/ThroughputHistory.svelte b/frontend/src/lib/components/throughput/ThroughputHistory.svelte new file mode 100644 index 0000000..90e6d70 --- /dev/null +++ b/frontend/src/lib/components/throughput/ThroughputHistory.svelte @@ -0,0 +1,771 @@ + + +
+
+
+

+ {#if !filtersActive}{/if} + {filtersActive ? 'Filtered entries' : 'Recent entries'} +

+ {filtersActive ? 'Matching runs' : 'Last 30 days'} +
+ +
+ + {#if showFilters} +
{ + e.preventDefault(); + onApplyFilters(); + }} + > + + + + + +
+ + {#if filtersActive} + + {/if} +
+
+ {/if} + + {#if errorMessage} + + {/if} + +
+
+ + + + + + + + Edit +
+ + {#if isLoading} + {#each Array(5) as _, i (i)} + + {/each} + {:else} + {#each paginatedEntries as entry (entry.id)} + {@const dest = destinationOf(entry)} +
+ + Date + {formatDate(entry.production_date)} + + + Product + {entry.product_name_snapshot} + + + Packed + {packedMain(entry)} + {packedDetail(entry)} + + + Total kg + {formatNumber(entry.calculated_kg)} kg + + + Packed by + {entry.staff_name ?? '—'} + + + Destination + {dest.label} + {#if dest.detail}{dest.detail}{/if} + + + + + + {#if entry.notes} +

Note{entry.notes}

+ {/if} +
+ {:else} +
+ {#if filtersActive} +

No entries match your search

+

Try a wider date range, or clear the filters to see everything.

+ + {:else} +

No packing logged yet

+

Use the inline entry area above to add your first run. It appears here immediately, newest first.

+ {/if} +
+ {/each} + {/if} +
+ + {#if sortedEntries.length > 0} + + {/if} +
+ + diff --git a/frontend/src/lib/components/throughput/ThroughputSuccessOverlay.svelte b/frontend/src/lib/components/throughput/ThroughputSuccessOverlay.svelte new file mode 100644 index 0000000..cbed4fe --- /dev/null +++ b/frontend/src/lib/components/throughput/ThroughputSuccessOverlay.svelte @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/lib/components/throughput/ThroughputSummary.svelte b/frontend/src/lib/components/throughput/ThroughputSummary.svelte new file mode 100644 index 0000000..64216c6 --- /dev/null +++ b/frontend/src/lib/components/throughput/ThroughputSummary.svelte @@ -0,0 +1,256 @@ + + +
+
+ +

Throughput Overview

+
+ {#each MIX_RANGES as range (range.key)} + + {/each} +
+
+
+
+
Today
+
{formatNumber(heroStats.today)} kg
+

{formatDate(today)}

+
+
+
This week
+
{formatNumber(heroStats.thisWeek)} kg
+

{weekRangeLabel}

+
+
+
4-week average
+
{formatNumber(heroStats.avgFourWeek)} kg
+

Per week, last 4 weeks

+
+
+
+
+
Horse Mix
+
{formatNumber(mixTotals.horse)} kg
+

PHF Horsemix · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}

+
+
+
Grain Mix
+
{formatNumber(mixTotals.grain)} kg
+

All other customers · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}

+
+
+
+ + diff --git a/frontend/src/lib/components/throughput/utils.ts b/frontend/src/lib/components/throughput/utils.ts new file mode 100644 index 0000000..49697ff --- /dev/null +++ b/frontend/src/lib/components/throughput/utils.ts @@ -0,0 +1,77 @@ +import type { ThroughputEntry } from '$lib/types'; + +export type SortKey = 'date' | 'product' | 'packed' | 'total' | 'staff' | 'destination' | 'notes'; +export type SortDirection = 'asc' | 'desc'; + +export type ConfettiPiece = { + left: number; + delay: number; + duration: number; + color: string; + rotate: number; + drift: number; + size: number; +}; + +export const CONFETTI_COLORS = ['#16a34a', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444']; + +export const MIX_RANGES = [ + { key: '7d', label: '7 days', sub: 'last 7 days', days: 7 }, + { key: '4w', label: '4 weeks', sub: 'last 4 weeks', days: 28 }, + { key: '6w', label: '6 weeks', sub: 'last 6 weeks', days: 42 }, + { key: '12w', label: '12 weeks', sub: 'last 12 weeks', days: 84 } +] as const; + +export function compareText(a: string | null | undefined, b: string | null | undefined) { + return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' }); +} + +export function compareDate(a: string | null | undefined, b: string | null | undefined) { + const aTime = a ? Date.parse(a) : Number.NEGATIVE_INFINITY; + const bTime = b ? Date.parse(b) : Number.NEGATIVE_INFINITY; + return aTime - bTime; +} + +export function toISODate(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +export function ausToday(): Date { + const ymd = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Australia/Sydney', + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).format(new Date()); + const [y, m, d] = ymd.split('-').map(Number); + return new Date(y, m - 1, d); +} + +export function startOfWeekMonday(d: Date): Date { + const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const dow = (start.getDay() + 6) % 7; + start.setDate(start.getDate() - dow); + return start; +} + +export function addDays(d: Date, days: number): Date { + const next = new Date(d); + next.setDate(next.getDate() + days); + return next; +} + +export function buildConfetti(colors = CONFETTI_COLORS): ConfettiPiece[] { + return Array.from({ length: 42 }, (_, i) => ({ + left: Math.random() * 100, + delay: Math.random() * 1.2, + duration: 1 + Math.random() * 0.8, + color: colors[i % colors.length], + rotate: 220 + Math.random() * 360, + drift: (Math.random() - 0.5) * 60, + size: 6 + Math.random() * 6 + })); +} + +export function isStockEntry(entry: ThroughputEntry): boolean { + return entry.for_stock || (!entry.for_order && /stock/i.test(entry.notes ?? '')); +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 96d8109..a6a2d86 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -7,7 +7,7 @@ import '$lib/theme'; import { beforeNavigate, afterNavigate } from '$app/navigation'; import { page } from '$app/state'; - import ClientShell from '$lib/components/ClientShell.svelte'; + import AppShell from '$lib/components/AppShell.svelte'; import CustomerPortalShell from '$lib/components/CustomerPortalShell.svelte'; import Toast from '$lib/components/Toast.svelte'; import { clientSession } from '$lib/session'; @@ -50,9 +50,9 @@ {@render children()} {:else} - + {@render children()} - + {/if} diff --git a/frontend/src/routes/throughput/+page.svelte b/frontend/src/routes/throughput/+page.svelte index eb413b0..3afb874 100644 --- a/frontend/src/routes/throughput/+page.svelte +++ b/frontend/src/routes/throughput/+page.svelte @@ -1,14 +1,29 @@
-
-
- -

Throughput Overview

-
- {#each MIX_RANGES as range (range.key)} - - {/each} -
-
-
-
-
Today
-
{formatNumber(heroStats.today)} kg
-

{formatDate(today)}

-
-
-
This week
-
{formatNumber(heroStats.thisWeek)} kg
-

{weekRangeLabel}

-
-
-
4-week average
-
{formatNumber(heroStats.avgFourWeek)} kg
-

Per week, last 4 weeks

-
-
-
-
-
Horse Mix
-
{formatNumber(mixTotals.horse)} kg
-

PHF Horsemix · {mixRange.sub}

-
-
-
Grain Mix
-
{formatNumber(mixTotals.grain)} kg
-

All other customers · {mixRange.sub}

-
-
-
+
-
-
-
-

{editingId != null ? 'Edit packing run' : 'Add a packing run'}

-
- {#if editingId != null} - - {/if} -
+ -
{ e.preventDefault(); addEntry(); }}> -
- Date - -
-
- Product - -
-
- Packed -
- - - {#if nType === 'bags'} - - - {/if} -
- {#if nType === 'bags' && addTotalKg !== null} - = {formatNumber(addTotalKg)} kg total - {/if} -
-
- Packed by - -
-
- Destination -
- -
- - {#if nForOrder} - - {/if} -
-
- - {#if isSplit} - - {/if} -
-
-
-
- -
- -
- {#if showNote} -
- - -
- {:else} - - {/if} - {#if addError} - {addError} - {/if} -
-
-
- -
-
-
-

- {#if !filtersActive}{/if} - {filtersActive ? 'Filtered entries' : 'Recent entries'} -

- {filtersActive ? 'Matching runs' : 'Last 30 days'} -
- -
- - {#if showFilters} -
{ - e.preventDefault(); - applyFilters(); - }} - > - - - - - -
- - {#if filtersActive} - - {/if} -
-
- {/if} - - {#if errorMessage} - - {/if} - -
-
- - - - - - - - Edit -
- - {#if isLoading} - {#each Array(5) as _, i (i)} - - {/each} - {:else} - {#each paginatedEntries as entry (entry.id)} - {@const dest = destinationOf(entry)} -
- - Date - {formatDate(entry.production_date)} - - - Product - {entry.product_name_snapshot} - - - Packed - {packedMain(entry)} - {packedDetail(entry)} - - - Total kg - {formatNumber(entry.calculated_kg)} kg - - - Packed by - {entry.staff_name ?? '—'} - - - Destination - {dest.label} - {#if dest.detail}{dest.detail}{/if} - - - - - - {#if entry.notes} -

Note{entry.notes}

- {/if} -
- {:else} -
- {#if filtersActive} -

No entries match your search

-

Try a wider date range, or clear the filters to see everything.

- - {:else} -

No packing logged yet

-

Use the inline entry area above to add your first run. It appears here immediately, newest first.

- {/if} -
- {/each} - {/if} -
- - {#if sortedEntries.length > 0} - - {/if} -
+
{#if pendingDelete} - + {/if} {#if showSuccess} - + {/if} +