Release v0.1.23

This commit is contained in:
2026-06-15 11:59:04 +12:00
parent 250d6ab6a9
commit 8f9a7b8193
18 changed files with 3725 additions and 3298 deletions
+606
View File
@@ -0,0 +1,606 @@
<script lang="ts">
import { api } from '$lib/api';
import AuthGate from '$lib/components/AuthGate.svelte';
import WorkspaceBootCard from '$lib/components/app-shell/WorkspaceBootCard.svelte';
import WorkspaceQuickAccess from '$lib/components/app-shell/WorkspaceQuickAccess.svelte';
import WorkspaceSearchPalette from '$lib/components/app-shell/WorkspaceSearchPalette.svelte';
import WorkspaceSignedOutCard from '$lib/components/app-shell/WorkspaceSignedOutCard.svelte';
import WorkspaceTabletNav from '$lib/components/app-shell/WorkspaceTabletNav.svelte';
import { PALETTE_RESULT_LIMIT, buildSessionKey, filterSearchItems } from '$lib/components/app-shell/utils';
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
import { currentChangelog } from '$lib/changelog';
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
import { featureFlags } from '$lib/features';
import {
canCreateMixSession as sessionCanCreateMixSession,
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenDashboard as sessionCanOpenDashboard,
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster,
canOpenCustomerOrdering as sessionCanOpenCustomerOrdering,
canManageOrdering as sessionCanManageOrdering,
canOpenProductCosting as sessionCanOpenProductCosting,
canOpenReporting as sessionCanOpenReporting,
canOpenSettings as sessionCanOpenSettings,
canOpenThroughput as sessionCanOpenThroughput,
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
getWorkspaceRole,
getWorkspaceHomeHref as sessionWorkspaceHomeHref,
isWorkspaceRouteAllowed
} from '$lib/workspace-access';
import {
baseSearchItems,
buildClientNavEntries,
clientBreadcrumbs,
dashboardItem,
editorItem,
ingredientsEditorItem,
footerLinks,
mixCalculatorItem,
orderingItem,
orderingManageChildren,
orderingManageGroup,
pageTitle,
productCostingItem,
reportingItem,
throughputItem,
type FooterLink,
type NavEntry,
type SearchItem,
type NavItem,
workingDocumentItems
} from '$lib/navigation/client-navigation';
import { onMount } from 'svelte';
import packageInfo from '../../../package.json';
let { children } = $props();
const isRootRoute = $derived(page.url.pathname === '/');
let paletteOpen = $state(false);
let paletteQuery = $state('');
let quickMenuOpen = $state(false);
let userMenuOpen = $state(false);
let navOpen = $state(false);
let showBottomNav = $state(false);
let whatsNewOpen = $state(false);
// The user identity we've already run the "what's new" check for this mount,
// so the dialog is evaluated once per login rather than on every navigation.
let whatsNewCheckedFor = $state<string | null>(null);
let isRestoringSession = $state(false);
let restoredSessionKey = $state<string | null>(null);
let seededSearchItems = $state<SearchItem[]>([]);
let seededSearchKey = $state<string | null>(null);
let bootDelayDone = $state(false);
const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear();
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
const shellTitle = $derived(routeGuardPending ? 'Loading Workspace' : pageTitle(page.url.pathname));
const shellBreadcrumbs = $derived(
routeGuardPending ? clientBreadcrumbs(workspaceHomeHref, $clientSession) : clientBreadcrumbs(page.url.pathname, $clientSession)
);
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
const visibleWorkingDocumentItems = $derived(
!$clientSession
? workingDocumentItems
: workingDocumentItems.filter((item) => {
if (item.href === '/mixes') return canOpenMixMaster;
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
})
);
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
// Ordering serves two audiences: internal staff get the management console
// (/ordering/manage), customers get the catalogue (/ordering).
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
// Internal staff get the collapsible "Order Management" family (queue +
// products/customers/pricing/settings/integrations); customers get the single
// catalogue link. Either way it becomes one NavEntry the rail can render.
const visibleOrderingEntry = $derived<NavEntry | null>(
canManageOrdering
? { kind: 'group', group: orderingManageGroup }
: canOpenCustomerOrdering
? { kind: 'item', item: orderingItem }
: null
);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the
// standalone operations/insights modules. Built from the same access-filtered
// items, so a role only ever sees the families it may open.
const navEntries = $derived(
buildClientNavEntries({
dashboard: visibleDashboardItem,
costing: [
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
...visibleWorkingDocumentItems
],
throughput: visibleThroughputItem,
ordering: visibleOrderingEntry,
reporting: visibleReportingItem
})
);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([
...(!isOperationsUser ? footerLinks : [])
] as FooterLink[]);
const primaryBottomNavigation = $derived(
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...visibleWorkingDocumentItems.slice(0, 2)
]
);
const workingDocumentsActive = $derived(
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
);
const visibleBaseSearchItems = $derived(
baseSearchItems.filter((item) => {
if (item.href === '/') return canOpenDashboard;
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/mixes/new') return canCreateMixWorksheet;
if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession);
if (item.href === '/editor') return canOpenEditor;
if (item.href === '/ingredients') return canOpenEditor;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings;
return true;
})
);
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone));
function openPalette(query = '') {
paletteQuery = query;
paletteOpen = true;
quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
}
function syncViewport() {
showBottomNav = window.innerWidth <= 1180;
if (!showBottomNav) {
navOpen = false;
}
}
async function runSearchItem(item: SearchItem) {
paletteOpen = false;
paletteQuery = '';
await goto(item.href);
}
async function openSettings() {
quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
await goto('/settings');
}
async function signOut() {
try {
if ($clientSession?.role === 'internal') {
await api.internalLogout();
} else {
await api.clientLogout();
}
} catch {
// Clearing the local session remains the safe fallback.
} finally {
clientSession.clear();
}
}
const paletteState = $derived(filterSearchItems(searchItems, paletteQuery, PALETTE_RESULT_LIMIT));
$effect(() => {
page.url.pathname;
quickMenuOpen = false;
userMenuOpen = false;
paletteOpen = false;
paletteQuery = '';
navOpen = false;
});
$effect(() => {
const hydrated = $sessionHydrated;
const sessionKey = buildSessionKey($clientSession);
if (!hydrated) {
return;
}
if (!sessionKey) {
isRestoringSession = false;
restoredSessionKey = null;
return;
}
if (restoredSessionKey === sessionKey) {
return;
}
restoredSessionKey = sessionKey;
isRestoringSession = true;
// Internal Hunter Stock Feeds users are refreshed against /api/access/me;
// legacy client-portal users keep using /api/auth/client/session.
const refresh = $clientSession?.role === 'internal' ? api.internalSession() : api.clientSession();
refresh
.then((session) => {
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredSessionKey = null;
clientSession.clear();
})
.finally(() => {
isRestoringSession = false;
});
});
// Search palette items are seeded lazily — three list endpoints worth of
// data only when the user actually opens the palette, not on every login or
// navigation. Subsequent opens hit the api.ts cache.
$effect(() => {
const hydrated = $sessionHydrated;
const session = $clientSession;
const sessionKey = buildSessionKey(session);
const shouldSeed = paletteOpen;
if (!hydrated || !session || !sessionKey) {
seededSearchItems = [];
seededSearchKey = null;
return;
}
if (!shouldSeed || seededSearchKey === sessionKey) {
return;
}
seededSearchKey = sessionKey;
Promise.all([
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
? api.mixCalculatorSessions()
: Promise.resolve([])
])
.then(([mixes, sessions]) => {
if (seededSearchKey !== sessionKey) {
return;
}
seededSearchItems = [
...mixes.map((mix) => ({
href: `/mixes/${mix.id}`,
label: mix.name,
description: `Mix · ${mix.client_name} · ${mix.total_mix_kg}kg`,
keywords: `mix ${mix.name} ${mix.client_name} ${mix.notes ?? ''} ${mix.ingredients.map((ingredient) => ingredient.raw_material_name).join(' ')}`
})),
...sessions.map((savedSession) => ({
href: `/mix-calculator/${savedSession.id}`,
label: `${savedSession.session_number} · ${savedSession.product_name}`,
description: `Mix Session · ${savedSession.prepared_by_name} · ${savedSession.mix_date}`,
keywords: `mix calculator session ${savedSession.session_number} ${savedSession.product_name} ${savedSession.mix_name} ${savedSession.client_name} ${savedSession.prepared_by_name} ${savedSession.notes ?? ''}`
}))
];
})
.catch(() => {
if (seededSearchKey === sessionKey) {
seededSearchItems = [];
}
});
});
$effect(() => {
if ($sessionHydrated && !$clientSession && !isRootRoute) {
goto('/', { replaceState: true });
}
});
$effect(() => {
if (!$sessionHydrated || !$clientSession) {
return;
}
if (currentRouteAllowed || page.url.pathname === workspaceHomeHref) {
return;
}
goto(workspaceHomeHref, { replaceState: true });
});
// Surface the release notes once per version per user, right after login.
// hasSeenVersion keeps this to a single appearance: once dismissed (which
// records the version), it won't return until the next version ships.
$effect(() => {
if (!$sessionHydrated || !$clientSession || !currentChangelog) {
return;
}
const userKey = buildSessionKey($clientSession);
if (!userKey) {
return;
}
if (whatsNewCheckedFor === userKey) {
return;
}
whatsNewCheckedFor = userKey;
if (!hasSeenVersion(userKey, currentChangelog.version)) {
whatsNewOpen = true;
}
});
function dismissWhatsNew() {
const userKey = buildSessionKey($clientSession);
if (userKey && currentChangelog) {
markVersionSeen(userKey, currentChangelog.version);
}
whatsNewOpen = false;
}
onMount(() => {
const bootTimer = window.setTimeout(() => {
bootDelayDone = true;
}, 1500);
syncViewport();
const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const isTypingField =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target?.isContentEditable;
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
event.preventDefault();
openPalette();
}
if (event.key === 'Escape') {
paletteOpen = false;
quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
}
};
window.addEventListener('keydown', handleKeydown);
window.addEventListener('resize', syncViewport);
return () => {
window.clearTimeout(bootTimer);
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('resize', syncViewport);
};
});
const userInitials = $derived(
($clientSession?.name ?? '')
.split(' ')
.slice(0, 2)
.map((w: string) => w[0])
.join('')
.toUpperCase() || '?'
);
</script>
<svelte:head>
<title>{shellTitle} | Hunter Premium Produce</title>
</svelte:head>
{#if !$clientSession}
<div class="signed-out-shell">
{#if isRootRoute}
{@render children()}
{:else if showWorkspaceBoot}
<WorkspaceBootCard />
{:else}
<WorkspaceSignedOutCard />
{/if}
</div>
{:else}
<div class="app-shell">
{#if !showBottomNav}
<ClientPrimaryRail
currentPath={shellPathname}
entries={navEntries}
brandHref={workspaceHomeHref}
footerItems={visibleFooterLinks}
{appVersion}
{currentYear}
{canOpenSettings}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
{/if}
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
<ClientTopbar
breadcrumbs={shellBreadcrumbs}
title={shellTitle}
sessionHydrated={$sessionHydrated}
session={$clientSession}
{userInitials}
{userMenuOpen}
{canUseWorkspaceSearch}
{canOpenSettings}
onOpenPalette={() => canUseWorkspaceSearch && openPalette()}
onToggleUserMenu={() => {
userMenuOpen = !userMenuOpen;
quickMenuOpen = false;
}}
onOpenSettings={openSettings}
onSignOut={signOut}
onShowWhatsNew={() => (whatsNewOpen = true)}
/>
<main class="content">
<AuthGate
blocked={routeGuardPending}
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
title={isRestoringSession ? 'Restoring your client workspace.' : 'Routing you to an authorised page.'}
detail={
isRestoringSession
? 'Refreshing the saved session before rendering workspace content.'
: `The ${workspaceRole} role cannot open this route, so the workspace is redirecting before any page content mounts.`
}
>
{@render children()}
</AuthGate>
</main>
</div>
<WorkspaceQuickAccess
bind:quickMenuOpen
{canOpenMixMaster}
{canCreateMixWorksheet}
{canOpenMixCalculator}
{canCreateMixSession}
{canUseWorkspaceSearch}
onOpenPalette={() => openPalette('')}
/>
</div>
<WorkspaceTabletNav
bind:navOpen
{showBottomNav}
{primaryBottomNavigation}
{visibleDashboardItem}
{visibleMixCalculatorItem}
{visibleProductCostingItem}
{visibleThroughputItem}
{visibleEditorItem}
{visibleReportingItem}
{visibleWorkingDocumentItems}
{visibleFooterLinks}
{orderingManageGroup}
{orderingManageChildren}
{orderingItem}
{canManageOrdering}
{canOpenCustomerOrdering}
{canCreateMixWorksheet}
{canCreateMixSession}
{canOpenSettings}
{canUseWorkspaceSearch}
pagePath={page.url.pathname}
onOpenPalette={() => openPalette('')}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
{/if}
{#if $clientSession && whatsNewOpen && currentChangelog}
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
{/if}
{#if $clientSession && paletteOpen}
<WorkspaceSearchPalette
bind:query={paletteQuery}
filteredSearchItems={paletteState.filteredItems}
hiddenResultCount={paletteState.hiddenResultCount}
onClose={() => (paletteOpen = false)}
onRunSearchItem={runSearchItem}
/>
{/if}
<style>
.app-shell {
display: grid;
grid-template-columns: 252px minmax(0, 1fr);
min-height: 100vh;
background: var(--color-bg-app);
}
.signed-out-shell {
min-height: 100vh;
padding: 1.5rem;
}
.main-shell {
min-width: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
overflow: hidden;
background: var(--color-bg-app);
}
.content {
--content-padding: 1.34rem;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
padding: var(--content-padding);
overflow: auto;
background: var(--color-bg-app);
}
.main-shell.bottom-nav-layout .content {
padding-bottom: 7.25rem;
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 1fr;
}
.content {
--content-padding: 1rem;
padding: 1rem;
}
}
@media (min-width: 1181px) {
.bottom-nav-layout .content {
padding-bottom: 1.34rem;
}
}
@media (max-width: 720px) {
.content {
--content-padding: 0.92rem;
padding: 0.92rem;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
<script lang="ts">
import { LoaderCircle } from 'lucide-svelte';
</script>
<section class="locked-card loading-card workspace-boot-card" aria-live="polite">
<div class="workspace-boot-orb" aria-hidden="true">
<LoaderCircle size={26} strokeWidth={2.1} />
</div>
<p class="workspace-label">Checking Workspace</p>
<h2>Restoring your client workspace.</h2>
<p>Hold on while we reload your saved session and bring the app back into place.</p>
<div class="workspace-boot-progress" aria-hidden="true">
<span class="workspace-boot-progress-bar"></span>
</div>
</section>
<style>
.locked-card {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.loading-card {
min-height: 10rem;
}
.workspace-boot-card {
position: relative;
display: grid;
gap: 0.85rem;
align-items: start;
min-height: 14rem;
margin: 0 auto;
padding: 1.45rem;
overflow: hidden;
}
.workspace-boot-card::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at top right, color-mix(in srgb, var(--color-brand) 18%, transparent), transparent 42%),
linear-gradient(180deg, color-mix(in srgb, var(--color-brand) 4%, transparent), transparent 45%);
pointer-events: none;
}
.workspace-boot-card > * {
position: relative;
z-index: 1;
}
.workspace-boot-orb {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.35rem;
height: 3.35rem;
border-radius: 1rem;
background: color-mix(in srgb, var(--color-brand) 12%, var(--color-bg-surface));
color: var(--color-brand);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-brand) 16%, transparent);
}
.workspace-boot-orb :global(svg) {
animation: workspace-spin 1s linear infinite;
}
.workspace-boot-progress {
width: min(16rem, 100%);
height: 0.42rem;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--color-brand) 10%, var(--color-border));
}
.workspace-boot-progress-bar {
display: block;
width: 38%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--color-brand), color-mix(in srgb, var(--color-brand) 62%, white));
animation: workspace-progress 1.15s ease-in-out infinite;
transform-origin: left center;
}
.workspace-label {
color: var(--muted);
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
h2,
p {
margin: 0;
}
h2 {
margin-top: 0.35rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
}
p:last-of-type {
margin-top: 0.45rem;
color: var(--muted);
}
@keyframes workspace-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes workspace-progress {
0% { transform: translateX(-110%) scaleX(0.85); }
55% { transform: translateX(135%) scaleX(1.05); }
100% { transform: translateX(280%) scaleX(0.9); }
}
@media (prefers-reduced-motion: reduce) {
.workspace-boot-orb :global(svg),
.workspace-boot-progress-bar {
animation: none;
}
}
</style>
@@ -0,0 +1,161 @@
<script lang="ts">
let {
quickMenuOpen = $bindable(false),
canOpenMixMaster,
canCreateMixWorksheet,
canOpenMixCalculator,
canCreateMixSession,
canUseWorkspaceSearch,
onOpenPalette
}: {
quickMenuOpen?: boolean;
canOpenMixMaster: boolean;
canCreateMixWorksheet: boolean;
canOpenMixCalculator: boolean;
canCreateMixSession: boolean;
canUseWorkspaceSearch: boolean;
onOpenPalette: () => void;
} = $props();
</script>
{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canUseWorkspaceSearch}
<div class="quick-fab-wrap">
{#if quickMenuOpen}
<div class="menu-panel quick-fab-panel">
{#if canOpenMixMaster}
<a href="/mixes">Open mix costing</a>
{/if}
{#if canCreateMixWorksheet}
<a href="/mixes/new">Create mix worksheet</a>
{/if}
{#if canOpenMixCalculator}
<a href="/mix-calculator">Open mix calculator</a>
{/if}
{#if canCreateMixSession}
<a href="/mix-calculator">Create mix session</a>
{/if}
{#if canUseWorkspaceSearch}
<button type="button" onclick={onOpenPalette}>Search the workspace</button>
{/if}
</div>
{/if}
<button
aria-expanded={quickMenuOpen}
aria-label="Open quick access menu"
class="quick-fab"
type="button"
onclick={() => (quickMenuOpen = !quickMenuOpen)}
>
<span class={`quick-fab-plus ${quickMenuOpen ? 'open' : ''}`}></span>
<span>Quick Access</span>
</button>
</div>
{/if}
<style>
.menu-panel {
position: absolute;
top: calc(100% + 0.45rem);
right: 0;
z-index: 20;
min-width: 13rem;
display: grid;
gap: 0.18rem;
padding: 0.4rem;
border: 1px solid var(--color-border);
border-radius: 0.96rem;
background: var(--color-bg-elevated);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.quick-fab-wrap {
position: fixed;
right: max(1rem, env(safe-area-inset-right));
bottom: max(1rem, env(safe-area-inset-bottom));
z-index: 46;
display: grid;
justify-items: end;
gap: 0.6rem;
}
.quick-fab {
display: inline-flex;
align-items: center;
gap: 0.72rem;
padding: 0.88rem 1.05rem;
border: none;
border-radius: 999px;
background: var(--color-brand);
color: var(--color-on-brand);
box-shadow: none;
font-weight: 700;
letter-spacing: 0.01em;
cursor: pointer;
}
.quick-fab-panel {
position: static;
min-width: 15rem;
padding: 0.45rem;
border-radius: 1rem;
}
.quick-fab-plus {
position: relative;
width: 0.92rem;
height: 0.92rem;
flex-shrink: 0;
}
.quick-fab-plus::before,
.quick-fab-plus::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0.92rem;
height: 2px;
border-radius: 999px;
background: currentColor;
transform: translate(-50%, -50%);
transition: transform 140ms ease;
}
.quick-fab-plus::after {
transform: translate(-50%, -50%) rotate(90deg);
}
.quick-fab-plus.open::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.quick-fab-plus.open::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.menu-panel a,
.menu-panel button {
padding: 0.72rem 0.78rem;
border-radius: 0.78rem;
color: var(--color-text-primary);
text-align: left;
background: transparent;
border: none;
}
.menu-panel button {
cursor: pointer;
}
.menu-panel a:hover,
.menu-panel button:hover {
background: var(--panel-soft);
}
@media (max-width: 1180px) {
.quick-fab-wrap {
bottom: calc(max(0.8rem, env(safe-area-inset-bottom)) + 5.9rem);
}
}
</style>
@@ -0,0 +1,201 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { SearchItem } from '$lib/navigation/client-navigation';
let {
query = $bindable(''),
filteredSearchItems,
hiddenResultCount,
onClose,
onRunSearchItem
}: {
query?: string;
filteredSearchItems: SearchItem[];
hiddenResultCount: number;
onClose: () => void;
onRunSearchItem: (item: SearchItem) => void | Promise<void>;
} = $props();
let paletteInput: HTMLInputElement | null = null;
onMount(() => {
paletteInput?.focus();
});
</script>
<div class="palette-overlay" role="presentation" onclick={onClose}>
<div
class="palette"
role="dialog"
aria-modal="true"
aria-label="Workspace search"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => {
if (event.key === 'Escape') {
onClose();
}
}}
>
<div class="palette-input-row">
<span class="search-icon"></span>
<input bind:this={paletteInput} bind:value={query} placeholder="Search mixes, sessions, and pages..." />
<kbd>Esc</kbd>
</div>
<div class="palette-results">
{#if filteredSearchItems.length}
{#each filteredSearchItems as item}
<button class="palette-item" type="button" onclick={() => onRunSearchItem(item)}>
<div>
<strong>{item.label}</strong>
<span>{item.description}</span>
</div>
<small>{item.href}</small>
</button>
{/each}
{#if hiddenResultCount > 0}
<p class="palette-more">{hiddenResultCount} more {hiddenResultCount === 1 ? 'match' : 'matches'}, keep typing to narrow.</p>
{/if}
{:else}
<div class="palette-empty">
<strong>No results</strong>
<span>Try searching for mixes, sessions, or pages.</span>
</div>
{/if}
</div>
</div>
</div>
<style>
.palette-overlay {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: start center;
padding: 8vh 1rem 1rem;
background: rgba(11, 18, 14, 0.3);
backdrop-filter: blur(10px);
}
.palette {
width: min(44rem, 100%);
border: 1px solid var(--color-border);
border-radius: 1.2rem;
background: var(--color-bg-surface);
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
overflow: hidden;
}
.palette-input-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.8rem;
padding: 0.95rem 1rem;
border-bottom: 1px solid var(--line);
}
.palette-input-row input {
border: none;
outline: none;
background: transparent;
color: var(--text);
font-size: 0.98rem;
}
.palette-results {
max-height: 26rem;
overflow: auto;
padding: 0.5rem;
}
.palette-item,
.palette-empty {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.88rem 0.92rem;
border: none;
border-radius: 0.92rem;
text-align: left;
background: transparent;
}
.palette-item {
cursor: pointer;
}
.palette-item:hover {
background: var(--panel-soft);
}
.palette-item strong,
.palette-empty strong {
display: block;
font-size: 0.96rem;
}
.palette-item span,
.palette-empty span,
.palette-item small {
color: var(--muted);
}
.palette-item span {
display: block;
margin-top: 0.18rem;
font-size: 0.84rem;
}
.palette-item small {
flex-shrink: 0;
font-size: 0.76rem;
}
.palette-empty {
justify-content: flex-start;
}
.palette-more {
margin: 0.25rem 0.4rem 0.15rem;
padding: 0.5rem 0.52rem 0.2rem;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.78rem;
}
.search-icon {
position: relative;
display: inline-block;
width: 0.82rem;
height: 0.82rem;
border: 2px solid var(--color-text-muted);
border-radius: 999px;
}
.search-icon::after {
content: '';
position: absolute;
right: -0.28rem;
bottom: -0.18rem;
width: 0.42rem;
height: 2px;
border-radius: 999px;
background: var(--color-text-muted);
transform: rotate(45deg);
}
kbd {
padding: 0.1rem 0.42rem;
border: 1px solid var(--line-strong);
border-radius: 0.42rem;
color: var(--muted);
background: var(--color-bg-surface);
font-size: 0.76rem;
}
</style>
@@ -0,0 +1,47 @@
<section class="locked-card loading-card signed-out-card">
<p class="workspace-label">Checking Session</p>
<h2>Returning to the client login screen.</h2>
<p>Only authenticated client users can open workspace routes directly.</p>
</section>
<style>
.locked-card {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.loading-card {
min-height: 10rem;
}
.signed-out-card {
margin: 0 auto;
}
.workspace-label {
color: var(--muted);
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
h2,
p {
margin: 0;
}
h2 {
margin-top: 0.35rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
}
p:last-of-type {
margin-top: 0.45rem;
color: var(--muted);
}
</style>
@@ -0,0 +1,520 @@
<script lang="ts">
import { Calculator, LogOut, Menu, Plus, Search, Settings } from 'lucide-svelte';
import type { FooterLink, NavGroup, NavItem } from '$lib/navigation/client-navigation';
import { matchesRoute } from '$lib/navigation/client-navigation';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
let {
showBottomNav,
navOpen = $bindable(false),
primaryBottomNavigation,
visibleDashboardItem,
visibleMixCalculatorItem,
visibleProductCostingItem,
visibleThroughputItem,
visibleEditorItem,
visibleReportingItem,
visibleWorkingDocumentItems,
visibleFooterLinks,
orderingManageGroup,
orderingManageChildren,
orderingItem,
canManageOrdering,
canOpenCustomerOrdering,
canCreateMixWorksheet,
canCreateMixSession,
canOpenSettings,
canUseWorkspaceSearch,
pagePath,
onOpenPalette,
onOpenSettings,
onSignOut
}: {
showBottomNav: boolean;
navOpen?: boolean;
primaryBottomNavigation: NavItem[];
visibleDashboardItem: NavItem | null;
visibleMixCalculatorItem: NavItem | null;
visibleProductCostingItem: NavItem | null;
visibleThroughputItem: NavItem | null;
visibleEditorItem: NavItem | null;
visibleReportingItem: NavItem | null;
visibleWorkingDocumentItems: NavItem[];
visibleFooterLinks: FooterLink[];
orderingManageGroup: NavGroup;
orderingManageChildren: NavItem[];
orderingItem: NavItem;
canManageOrdering: boolean;
canOpenCustomerOrdering: boolean;
canCreateMixWorksheet: boolean;
canCreateMixSession: boolean;
canOpenSettings: boolean;
canUseWorkspaceSearch: boolean;
pagePath: string;
onOpenPalette: () => void;
onOpenSettings: () => void | Promise<void>;
onSignOut: () => void | Promise<void>;
} = $props();
function closeDrawer() {
navOpen = false;
}
</script>
{#if showBottomNav}
{#if navOpen}
<button aria-label="Close navigation" class="nav-backdrop" type="button" onclick={closeDrawer}></button>
{/if}
<nav class="bottom-nav" aria-label="Tablet navigation">
{#each primaryBottomNavigation as item}
{@const Icon = item.icon}
<a class:active={matchesRoute(item.href, pagePath)} href={item.href}>
<span class="bottom-nav-icon"><Icon size={18} strokeWidth={1.85} /></span>
<span>{item.label}</span>
</a>
{/each}
<button aria-expanded={navOpen} class:active={navOpen} type="button" onclick={() => (navOpen = !navOpen)}>
<span class="bottom-nav-icon"><Menu size={18} strokeWidth={1.85} /></span>
<span>More</span>
</button>
</nav>
{#if navOpen}
<div aria-label="Tablet navigation drawer" class="bottom-drawer" role="dialog" aria-modal="true">
<div class="drawer-handle"></div>
<div class="drawer-header">
<div>
<p class="workspace-label">Workspace Drawer</p>
<strong>Hunter Premium Produce</strong>
</div>
<button aria-label="Close drawer" class="nav-toggle" type="button" onclick={closeDrawer}>
<span></span>
</button>
</div>
<WorkspaceSearchTrigger
className="drawer-search"
placeholder="Search the workspace..."
onClick={() => canUseWorkspaceSearch && onOpenPalette()}
/>
<div class="drawer-grid">
<nav class="drawer-section" aria-label="All workspace pages">
{#if visibleDashboardItem}
{@const Icon = visibleDashboardItem.icon}
<a class:active={matchesRoute(visibleDashboardItem.href, pagePath)} href={visibleDashboardItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleDashboardItem.label}</span>
</a>
{/if}
{#if visibleMixCalculatorItem}
{@const Icon = visibleMixCalculatorItem.icon}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, pagePath)} href={visibleMixCalculatorItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleMixCalculatorItem.label}</span>
</a>
{/if}
{#if visibleProductCostingItem}
{@const Icon = visibleProductCostingItem.icon}
<a class:active={matchesRoute(visibleProductCostingItem.href, pagePath)} href={visibleProductCostingItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleProductCostingItem.label}</span>
{#if visibleProductCostingItem.badge}<span class="drawer-badge">{visibleProductCostingItem.badge}</span>{/if}
</a>
{/if}
{#if visibleThroughputItem}
{@const Icon = visibleThroughputItem.icon}
<a class:active={matchesRoute(visibleThroughputItem.href, pagePath)} href={visibleThroughputItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleThroughputItem.label}</span>
{#if visibleThroughputItem.badge}<span class="drawer-badge">{visibleThroughputItem.badge}</span>{/if}
</a>
{/if}
{#if visibleEditorItem}
{@const Icon = visibleEditorItem.icon}
<a class:active={matchesRoute(visibleEditorItem.href, pagePath)} href={visibleEditorItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleEditorItem.label}</span>
{#if visibleEditorItem.badge}<span class="drawer-badge">{visibleEditorItem.badge}</span>{/if}
</a>
{/if}
{#if visibleReportingItem}
{@const Icon = visibleReportingItem.icon}
<a class:active={matchesRoute(visibleReportingItem.href, pagePath)} href={visibleReportingItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleReportingItem.label}</span>
</a>
{/if}
{#if canManageOrdering}
{@const GroupIcon = orderingManageGroup.icon}
<a class:active={pagePath === '/ordering/manage'} href="/ordering/manage" onclick={closeDrawer}>
<span class="nav-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span>{orderingManageGroup.label}</span>
</a>
<div class="drawer-sublist">
{#each orderingManageChildren as child}
{@const ChildIcon = child.icon}
<a class:active={matchesRoute(child.href, pagePath, child.exact)} href={child.href} onclick={closeDrawer}>
<span class="nav-icon"><ChildIcon size={18} strokeWidth={1.75} /></span>
<span>{child.label}</span>
</a>
{/each}
</div>
{:else if canOpenCustomerOrdering}
{@const Icon = orderingItem.icon}
<a class:active={matchesRoute(orderingItem.href, pagePath)} href={orderingItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{orderingItem.label}</span>
</a>
{/if}
{#if visibleWorkingDocumentItems.length}
<div class="drawer-sublist" id="drawer-working-documents-nav">
{#each visibleWorkingDocumentItems as item}
{@const Icon = item.icon}
<a class:active={matchesRoute(item.href, pagePath)} href={item.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{item.label}</span>
</a>
{/each}
</div>
{/if}
</nav>
<div class="drawer-section drawer-actions">
{#if canCreateMixWorksheet}
<a href="/mixes/new" onclick={closeDrawer}>
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
<span>Create mix worksheet</span>
</a>
{/if}
{#if canCreateMixSession}
<a href="/mix-calculator" onclick={closeDrawer}>
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
<span>Create mix session</span>
</a>
{/if}
{#if canOpenSettings}
<button type="button" onclick={onOpenSettings}>
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
<span>Change settings</span>
</button>
{/if}
{#if canUseWorkspaceSearch}
<button type="button" onclick={onOpenPalette}>
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
<span>Search the workspace</span>
</button>
{/if}
<button type="button" onclick={onSignOut}>
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
<span>Logout</span>
</button>
</div>
</div>
<div class="drawer-footer">
{#each visibleFooterLinks as item}
<a href={item.href} onclick={closeDrawer}>
<span>{item.label}</span>
<small>{item.shortLabel}</small>
</a>
{/each}
</div>
</div>
{/if}
{/if}
<style>
.nav-backdrop {
position: fixed;
inset: 0;
z-index: 48;
display: block;
border: none;
background: rgba(11, 18, 14, 0.28);
backdrop-filter: blur(4px);
}
.workspace-label {
color: var(--muted);
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.nav-toggle {
width: 2.05rem;
height: 2.05rem;
border: 1px solid var(--line);
border-radius: 0.68rem;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--panel);
color: var(--muted);
cursor: pointer;
}
.nav-toggle span,
.nav-toggle span::before,
.nav-toggle span::after {
width: 0.88rem;
height: 2px;
background: currentColor;
border-radius: 999px;
content: '';
}
.nav-toggle span {
position: relative;
display: inline-block;
}
.nav-toggle span::before,
.nav-toggle span::after {
position: absolute;
left: 0;
}
.nav-toggle span::before {
top: -0.28rem;
}
.nav-toggle span::after {
top: 0.28rem;
}
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--color-text-muted);
background: transparent;
border-radius: 0.55rem;
width: 1.6rem;
height: 1.6rem;
}
.bottom-nav,
.bottom-drawer {
display: none;
}
.drawer-sublist a {
position: relative;
}
@media (max-width: 1180px) {
.bottom-nav {
position: fixed;
left: max(0.8rem, env(safe-area-inset-left));
right: max(0.8rem, env(safe-area-inset-right));
bottom: max(0.8rem, env(safe-area-inset-bottom));
z-index: 45;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
padding: 0.6rem;
border: 1px solid var(--color-border);
border-radius: 1.35rem;
background: var(--color-bg-surface);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.bottom-nav a,
.bottom-nav button {
min-width: 0;
display: grid;
justify-items: center;
gap: 0.34rem;
padding: 0.62rem 0.38rem;
border: none;
border-radius: 1rem;
background: transparent;
color: var(--color-text-secondary);
text-align: center;
font-size: 0.74rem;
font-weight: 700;
cursor: pointer;
}
.bottom-nav a.active,
.bottom-nav button.active {
color: var(--color-brand);
background: var(--color-brand-tint);
}
.bottom-nav-icon {
width: 2.1rem;
height: 2.1rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.78rem;
color: var(--color-on-brand);
background: var(--color-brand);
font-size: 0.66rem;
letter-spacing: 0.04em;
}
.bottom-drawer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: grid;
gap: 1rem;
padding: 0.85rem 1rem calc(6.6rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
border-radius: 1.6rem 1.6rem 0 0;
background: var(--color-bg-surface);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
.drawer-handle {
width: 3.5rem;
height: 0.34rem;
margin: 0 auto;
border-radius: 999px;
background: var(--color-border);
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.drawer-header strong {
font-size: 1rem;
}
:global(.drawer-search) {
background: var(--color-bg-surface);
}
.drawer-grid {
display: grid;
gap: 0.9rem;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
}
.drawer-section {
display: grid;
gap: 0.4rem;
}
.drawer-section a,
.drawer-section button {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.82rem 0.86rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
}
.drawer-section a.active {
color: var(--color-brand-hover);
background: color-mix(in srgb, var(--color-brand) 11%, var(--color-bg-surface));
}
.drawer-badge {
margin-left: auto;
padding: 0.08rem 0.4rem;
border-radius: 999px;
background: var(--color-warning-tint);
color: var(--color-warning-text);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.drawer-sublist {
display: grid;
gap: 0.4rem;
}
.drawer-footer {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.drawer-footer a {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.82rem 0.9rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-weight: 600;
}
.drawer-footer small {
color: var(--muted);
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
}
@media (max-width: 900px) {
.drawer-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.bottom-nav {
left: max(0.55rem, env(safe-area-inset-left));
right: max(0.55rem, env(safe-area-inset-right));
bottom: max(0.55rem, env(safe-area-inset-bottom));
gap: 0.32rem;
padding: 0.45rem;
}
.bottom-nav a,
.bottom-nav button {
padding: 0.55rem 0.2rem;
font-size: 0.68rem;
}
.bottom-nav-icon {
width: 1.9rem;
height: 1.9rem;
font-size: 0.6rem;
}
.bottom-drawer {
padding: 0.75rem 0.8rem calc(6.3rem + env(safe-area-inset-bottom));
}
.drawer-footer {
grid-template-columns: 1fr;
}
}
</style>
@@ -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
};
}
@@ -0,0 +1,538 @@
<script lang="ts">
import { Plus, TriangleAlert, X } from 'lucide-svelte';
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
import type { ThroughputProduct, ThroughputQuantityType } from '$lib/types';
let {
products,
editingId,
saving,
nDate = $bindable(''),
nProductId = $bindable(''),
nQuantity = $bindable(''),
nType = $bindable<ThroughputQuantityType>('bags'),
nBagSize = $bindable(''),
nStaff = $bindable(''),
nNotes = $bindable(''),
nForOrder = $bindable(false),
nForStock = $bindable(false),
nJobNumber = $bindable(''),
nStockQty = $bindable(''),
showNote = $bindable(false),
addError,
isSplit,
addTotalKg,
formatNumber,
onSubmit,
onDismissNote,
onCancelEdit,
composerRef = $bindable<HTMLElement | null>(null)
}: {
products: ThroughputProduct[];
editingId: number | null;
saving: boolean;
nDate?: string;
nProductId?: string;
nQuantity?: string;
nType?: ThroughputQuantityType;
nBagSize?: string;
nStaff?: string;
nNotes?: string;
nForOrder?: boolean;
nForStock?: boolean;
nJobNumber?: string;
nStockQty?: string;
showNote?: boolean;
addError: string;
isSplit: boolean;
addTotalKg: number | null;
formatNumber: (value: number | null | undefined, digits?: number) => string;
onSubmit: () => void;
onDismissNote: () => void;
onCancelEdit: () => void;
composerRef?: HTMLElement | null;
} = $props();
</script>
<div class="composer" class:editing={editingId != null} bind:this={composerRef}>
<div class="composer-head">
<div class="composer-title">
<h2>{editingId != null ? 'Edit packing run' : 'Add a packing run'}</h2>
</div>
{#if editingId != null}
<button type="button" class="cancel-edit" onclick={onCancelEdit}>
<X size={16} strokeWidth={2.4} /> Cancel edit
</button>
{/if}
</div>
<form class="add-row" onsubmit={(e) => { e.preventDefault(); onSubmit(); }}>
<div class="add-cell">
<span class="cell-label">Date</span>
<input type="date" bind:value={nDate} aria-label="Production date" />
</div>
<div class="add-cell add-product">
<span class="cell-label">Product</span>
<ThroughputProductPicker {products} bind:productId={nProductId} inputId="throughput-add-product" />
</div>
<div class="add-cell">
<span class="cell-label">Packed</span>
<div class="packed-inputs">
<input
class="qty"
type="number"
min="0"
step="0.01"
inputmode="decimal"
bind:value={nQuantity}
placeholder={nType === 'bags' ? 'Bags' : 'Total kg'}
aria-label={nType === 'bags' ? 'Number of bags' : 'Total kilograms'}
/>
<select class="unit" bind:value={nType} aria-label="Bags or kilograms">
<option value="bags">bags</option>
<option value="kg">kg (bulka)</option>
</select>
{#if nType === 'bags'}
<span class="times" aria-hidden="true">×</span>
<input
class="bag"
type="number"
min="0"
step="0.01"
inputmode="decimal"
bind:value={nBagSize}
placeholder="kg/bag"
aria-label="Kilograms per bag"
/>
{/if}
</div>
{#if nType === 'bags' && addTotalKg !== null}
<span class="packed-total">= {formatNumber(addTotalKg)} kg total</span>
{/if}
</div>
<div class="add-cell">
<span class="cell-label">Packed by</span>
<input type="text" bind:value={nStaff} placeholder="Name" aria-label="Packed by" />
</div>
<div class="add-cell add-dest">
<span class="cell-label">Destination</span>
<div class="dest-rows">
<div class="dest-line">
<label class="dest-toggle" class:on={nForOrder}>
<input type="checkbox" bind:checked={nForOrder} /> For an order
</label>
{#if nForOrder}
<input
class="dest-input"
type="text"
bind:value={nJobNumber}
placeholder="Job number (Order Circle)"
aria-label="Job number"
/>
{/if}
</div>
<div class="dest-line">
<label class="dest-toggle" class:on={nForStock}>
<input type="checkbox" bind:checked={nForStock} /> For stock
</label>
{#if isSplit}
<input
class="dest-input"
type="number"
min="0"
step="0.01"
inputmode="decimal"
bind:value={nStockQty}
placeholder={`To stock (${nType === 'bags' ? 'bags' : 'kg'})`}
aria-label="Amount going to stock"
/>
{/if}
</div>
</div>
</div>
<div class="add-cell add-action">
<button type="submit" class="add-entry-button" disabled={saving}>
<Plus size={18} strokeWidth={2.6} />
<span>{saving ? 'Saving…' : editingId != null ? 'Save' : 'Add'}</span>
</button>
</div>
<div class="add-extra">
{#if showNote}
<div class="note-field">
<input
class="note-input"
type="text"
bind:value={nNotes}
placeholder="Note (optional)"
aria-label="Note"
/>
<button
type="button"
class="note-dismiss"
title="Remove note"
aria-label="Remove note"
onclick={onDismissNote}
>
<X size={16} strokeWidth={2.4} />
</button>
</div>
{:else}
<button type="button" class="link-button" onclick={() => (showNote = true)}>+ Add a note</button>
{/if}
{#if addError}
<span class="add-error"><TriangleAlert size={15} strokeWidth={2.4} /> {addError}</span>
{/if}
</div>
</form>
</div>
<style>
.composer {
display: grid;
gap: 0.85rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
border-radius: 1rem;
background: var(--color-bg-surface);
box-shadow: 0 16px 34px -28px rgba(15, 23, 42, 0.28);
/* The product picker menu is absolutely positioned and needs to escape the
card bounds without being cut off. */
overflow: visible;
}
.composer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.45rem 0;
}
.composer-title h2 {
margin: 0;
font-size: 1.22rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.add-row {
display: grid;
grid-template-columns: 7.5rem minmax(19rem, 1.7fr) minmax(14rem, 1.15fr) minmax(7rem, 0.65fr) minmax(14rem, 1.2fr) auto;
gap: 0.75rem 0.85rem;
align-items: start;
padding: 0 1.45rem 1.25rem;
}
.add-cell {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.add-row input:not([type='checkbox']),
.add-row select {
width: 100%;
min-height: 48px;
padding: 0.62rem 0.78rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.8rem;
font-size: 0.98rem;
color: var(--color-text-primary);
background: var(--color-bg-surface);
}
.add-row input:not([type='checkbox']):focus-visible,
.add-row select:focus-visible,
.cancel-edit:focus-visible,
.add-entry-button:focus-visible,
.note-dismiss:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
.packed-inputs {
display: flex;
align-items: center;
gap: 0.4rem;
}
.packed-inputs .qty {
flex: 1 1 4.5rem;
min-width: 0;
}
.packed-inputs .unit {
flex: 0 0 4.7rem;
width: 4.7rem;
}
.packed-inputs .bag {
flex: 0 0 6rem;
width: 6rem;
}
.packed-inputs .times {
flex: 0 0 auto;
color: var(--color-text-secondary);
font-weight: 700;
}
.packed-total {
margin-top: 0.3rem;
font-size: 0.84rem;
font-weight: 650;
color: var(--color-success);
font-variant-numeric: tabular-nums;
}
.add-dest {
gap: 0.4rem;
}
.dest-rows {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dest-line {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dest-line .dest-toggle {
flex: 0 0 auto;
min-width: 8.5rem;
}
.dest-line .dest-input {
flex: 1 1 auto;
min-width: 0;
width: auto;
}
.dest-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.68rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.72rem;
background: var(--color-bg-surface);
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.dest-toggle input {
width: 1.2rem;
height: 1.2rem;
min-height: 0;
margin: 0;
flex-shrink: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.dest-toggle.on {
border-color: var(--color-brand);
background: var(--color-brand-tint);
color: var(--color-success);
}
.dest-input {
width: 100%;
}
.add-action {
justify-content: center;
}
.add-entry-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 48px;
padding: 0.62rem 1.28rem;
background: var(--color-brand);
color: #fff;
border: 1px solid var(--color-brand);
border-radius: 0.8rem;
font-size: 0.98rem;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
transition: background-color 160ms ease;
}
.add-entry-button:hover:not(:disabled) {
background: #126a33;
}
.add-entry-button:disabled {
opacity: 0.65;
cursor: progress;
}
.add-extra {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
padding-top: 0.15rem;
}
.link-button {
padding: 0.25rem 0;
background: none;
border: 0;
color: var(--color-success);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
.link-button:hover {
text-decoration: underline;
}
.note-field {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1 1 16rem;
max-width: 28rem;
}
.note-input {
flex: 1 1 auto;
min-width: 0;
}
.note-dismiss,
.cancel-edit {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
}
.note-dismiss {
flex-shrink: 0;
width: 2.1rem;
height: 2.1rem;
padding: 0;
border-radius: 0.55rem;
transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease;
}
.cancel-edit {
gap: 0.35rem;
padding: 0.5rem 0.85rem;
border-radius: 0.6rem;
font-size: 0.92rem;
font-weight: 600;
}
.note-dismiss:hover,
.cancel-edit:hover {
color: var(--color-text-primary);
border-color: var(--color-text-muted);
background: #fafbfc;
}
.add-error {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: #8a1622;
font-weight: 650;
font-size: 0.95rem;
}
.cell-label {
display: none;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-muted);
}
.composer.editing {
border-color: var(--color-brand);
box-shadow: 0 0 0 1px var(--color-brand) inset;
}
@media (max-width: 1440px) {
.add-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem 1rem;
}
.add-action {
justify-content: flex-end;
}
.add-entry-button {
width: 100%;
}
}
@media (max-width: 1040px) {
.add-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem 1rem;
}
.add-cell:nth-child(2),
.add-cell:nth-child(3),
.add-dest,
.add-action {
grid-column: 1 / -1;
}
.add-entry-button {
width: 100%;
}
}
@media (max-width: 760px) {
.composer-head {
padding-left: 1.15rem;
padding-right: 1.15rem;
}
.composer-title h2 {
font-size: 1.28rem;
}
.add-row {
grid-template-columns: 1fr 1fr;
gap: 0.85rem 1rem;
padding: 0 1.15rem 1.15rem;
}
.add-cell .cell-label {
display: block;
}
.add-cell:nth-child(2),
.add-dest,
.add-action {
grid-column: 1 / -1;
}
.add-entry-button {
width: 100%;
}
}
</style>
@@ -0,0 +1,172 @@
<script lang="ts">
import { Trash2 } from 'lucide-svelte';
import type { ThroughputEntry } from '$lib/types';
let {
pendingDelete,
deletingId,
formatDate,
onCancel,
onConfirm,
dialogRef = $bindable<HTMLElement | null>(null)
}: {
pendingDelete: ThroughputEntry;
deletingId: number | null;
formatDate: (value: string) => string;
onCancel: () => void;
onConfirm: () => void;
dialogRef?: HTMLElement | null;
} = $props();
</script>
<div class="modal-backdrop" role="presentation" onclick={onCancel}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="delete-title"
tabindex="-1"
bind:this={dialogRef}
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') onCancel(); }}
>
<div class="modal-icon"><Trash2 size={22} strokeWidth={2.2} /></div>
<h2 id="delete-title" class="modal-title">Delete this run?</h2>
<p class="modal-text">
The <strong>{pendingDelete.product_name_snapshot}</strong> run from
{formatDate(pendingDelete.production_date)} will be permanently removed.
This cannot be undone.
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={onCancel}>Cancel</button>
<button
type="button"
class="modal-confirm"
disabled={deletingId === pendingDelete.id}
onclick={onConfirm}
>
{deletingId === pendingDelete.id ? 'Deleting…' : 'Delete run'}
</button>
</div>
</div>
</div>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(28rem, 100%);
display: grid;
gap: 0.7rem;
padding: 1.6rem;
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-bg-surface);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
animation: modal-pop 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.modal-card:focus {
outline: none;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
border-radius: 0.8rem;
background: #fdecee;
color: #b3261e;
}
.modal-title,
.modal-text {
margin: 0;
}
.modal-title {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text-primary);
}
.modal-text {
font-size: 0.98rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.55rem;
}
.modal-cancel,
.modal-confirm {
min-height: 44px;
padding: 0.6rem 1.15rem;
border-radius: 0.7rem;
font-size: 0.98rem;
font-weight: 650;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease;
}
.modal-cancel {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.modal-cancel:hover {
color: var(--color-text-primary);
border-color: var(--color-text-muted);
}
.modal-confirm {
background: #b3261e;
border: 1px solid #b3261e;
color: #fff;
}
.modal-confirm:hover:not(:disabled) {
background: #95201a;
}
.modal-confirm:disabled {
opacity: 0.65;
cursor: progress;
}
.modal-cancel:focus-visible,
.modal-confirm:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
@keyframes modal-pop {
from { opacity: 0; transform: translateY(6px) scale(0.985); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.modal-card {
animation: none;
}
}
</style>
@@ -0,0 +1,771 @@
<script lang="ts">
import { ArrowUpDown, ChevronLeft, ChevronRight, History, Pencil, Search, Trash2, TriangleAlert, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import type { SortKey } from '$lib/components/throughput/utils';
import type { ThroughputEntry, ThroughputProduct, ThroughputQuantityType } from '$lib/types';
let {
products,
filtersActive,
showFilters = $bindable(false),
dateFrom = $bindable(''),
dateTo = $bindable(''),
productFilter = $bindable(''),
staffFilter = $bindable(''),
typeFilter = $bindable<'' | ThroughputQuantityType>(''),
isLoading,
errorMessage,
sortKey,
highlightId,
deletingId,
sortedEntries,
paginatedEntries,
page,
totalPages,
pageStart,
pageEnd,
formatDate,
formatNumber,
packedMain,
packedDetail,
destinationOf,
onApplyFilters,
onClearFilters,
onToggleSort,
onStartEdit,
onRequestDelete
}: {
products: ThroughputProduct[];
filtersActive: boolean;
showFilters?: boolean;
dateFrom?: string;
dateTo?: string;
productFilter?: string;
staffFilter?: string;
typeFilter?: '' | ThroughputQuantityType;
isLoading: boolean;
errorMessage: string;
sortKey: SortKey;
highlightId: number | null;
deletingId: number | null;
sortedEntries: ThroughputEntry[];
paginatedEntries: ThroughputEntry[];
page: number;
totalPages: number;
pageStart: number;
pageEnd: number;
formatDate: (value: string) => string;
formatNumber: (value: number | null | undefined, digits?: number) => string;
packedMain: (entry: ThroughputEntry) => string;
packedDetail: (entry: ThroughputEntry) => string;
destinationOf: (entry: ThroughputEntry) => { label: string; detail: string | null };
onApplyFilters: () => void;
onClearFilters: () => void;
onToggleSort: (key: SortKey) => void;
onStartEdit: (entry: ThroughputEntry) => void;
onRequestDelete: (entry: ThroughputEntry) => void;
} = $props();
</script>
<div class="history-shell">
<div class="log-controls">
<div class="log-title">
<h2 class="history-title">
{#if !filtersActive}<History size={18} strokeWidth={2.1} />{/if}
<span>{filtersActive ? 'Filtered entries' : 'Recent entries'}</span>
</h2>
<span class="log-subtitle">{filtersActive ? 'Matching runs' : 'Last 30 days'}</span>
</div>
<button
type="button"
class="find-button"
class:active={showFilters}
aria-expanded={showFilters}
onclick={() => (showFilters = !showFilters)}
>
<Search size={18} strokeWidth={2.2} />
<span>Find past entries</span>
{#if filtersActive}<span class="find-dot" aria-label="filters applied"></span>{/if}
</button>
</div>
{#if showFilters}
<form
class="filters"
transition:fade={{ duration: 120 }}
onsubmit={(e) => {
e.preventDefault();
onApplyFilters();
}}
>
<label>
<span>From date</span>
<input type="date" bind:value={dateFrom} />
</label>
<label>
<span>To date</span>
<input type="date" bind:value={dateTo} />
</label>
<label>
<span>Product</span>
<select bind:value={productFilter}>
<option value="">All products</option>
{#each products as product (product.id)}
<option value={String(product.id)}>{product.name}</option>
{/each}
</select>
</label>
<label>
<span>Staff</span>
<input type="text" placeholder="Name" bind:value={staffFilter} />
</label>
<label>
<span>Packed as</span>
<select bind:value={typeFilter}>
<option value="">Bags or kg</option>
<option value="bags">Bags</option>
<option value="kg">Kilograms</option>
</select>
</label>
<div class="filter-actions">
<button type="submit" class="apply-button" disabled={isLoading}>
{isLoading ? 'Searching…' : 'Search'}
</button>
{#if filtersActive}
<button type="button" class="clear-button" onclick={onClearFilters}>
<X size={16} strokeWidth={2.4} /> Clear
</button>
{/if}
</div>
</form>
{/if}
{#if errorMessage}
<div class="error" role="alert">
<TriangleAlert size={20} strokeWidth={2.2} />
<span>{errorMessage}</span>
<button type="button" class="retry-button" onclick={onApplyFilters}>Try again</button>
</div>
{/if}
<div class="log">
<div class="log-head">
<button type="button" class="sort-head" class:active={sortKey === 'date'} onclick={() => onToggleSort('date')}>
<span>Date</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'product'} onclick={() => onToggleSort('product')}>
<span>Product</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'packed'} onclick={() => onToggleSort('packed')}>
<span>Packed</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'total'} onclick={() => onToggleSort('total')}>
<span>Total kg</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'staff'} onclick={() => onToggleSort('staff')}>
<span>Packed by</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'destination'} onclick={() => onToggleSort('destination')}>
<span>Destination</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head col-notes-head" class:active={sortKey === 'notes'} onclick={() => onToggleSort('notes')}>
<span>Notes</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<span class="col-actions-head">Edit</span>
</div>
{#if isLoading}
{#each Array(5) as _, i (i)}
<div class="row row-skeleton" aria-hidden="true">
<span class="sk sk-date"></span>
<span class="sk sk-product"></span>
<span class="sk sk-packed"></span>
<span class="sk sk-staff"></span>
<span class="sk sk-qa"></span>
</div>
{/each}
{:else}
{#each paginatedEntries as entry (entry.id)}
{@const dest = destinationOf(entry)}
<div class="row" class:just-added={entry.id === highlightId}>
<span class="col-date">
<span class="cell-label">Date</span>
{formatDate(entry.production_date)}
</span>
<span class="col-product">
<span class="cell-label">Product</span>
<span class="product-name">{entry.product_name_snapshot}</span>
</span>
<span class="col-packed">
<span class="cell-label">Packed</span>
<span class="packed-main">{packedMain(entry)}</span>
<span class="packed-detail">{packedDetail(entry)}</span>
</span>
<span class="col-total">
<span class="cell-label">Total kg</span>
<span class="total-kg">{formatNumber(entry.calculated_kg)} kg</span>
</span>
<span class="col-staff">
<span class="cell-label">Packed by</span>
{entry.staff_name ?? '—'}
</span>
<span class="col-dest">
<span class="cell-label">Destination</span>
<span
class="pill"
class:pill-stock={dest.label === 'Stock'}
class:pill-order={dest.label === 'Order'}
class:pill-split={dest.label === 'Split'}
>{dest.label}</span>
{#if dest.detail}<span class="dest-detail">{dest.detail}</span>{/if}
</span>
<span class="col-actions">
<button
type="button"
class="row-action"
title="Edit this run"
aria-label="Edit this run"
onclick={() => onStartEdit(entry)}
>
<Pencil size={16} strokeWidth={2.2} />
</button>
<button
type="button"
class="row-action row-action-danger"
title="Delete this run"
aria-label="Delete this run"
disabled={deletingId === entry.id}
onclick={() => onRequestDelete(entry)}
>
<Trash2 size={16} strokeWidth={2.2} />
</button>
</span>
{#if entry.notes}
<p class="row-notes"><span class="cell-label">Note</span>{entry.notes}</p>
{/if}
</div>
{:else}
<div class="empty">
{#if filtersActive}
<p class="empty-title">No entries match your search</p>
<p class="empty-help">Try a wider date range, or clear the filters to see everything.</p>
<button type="button" class="clear-button" onclick={onClearFilters}>
<X size={16} strokeWidth={2.4} /> Clear filters
</button>
{:else}
<p class="empty-title">No packing logged yet</p>
<p class="empty-help">Use the inline entry area above to add your first run. It appears here immediately, newest first.</p>
{/if}
</div>
{/each}
{/if}
</div>
{#if sortedEntries.length > 0}
<div class="pagination">
<p class="pagination-summary">{pageStart}-{pageEnd} of {sortedEntries.length}</p>
<div class="pagination-actions">
<button type="button" class="page-button" disabled={page === 1} onclick={() => (page = Math.max(1, page - 1))}>
<ChevronLeft size={16} strokeWidth={2.2} />
<span>Previous</span>
</button>
<span class="page-indicator">Page {page} of {totalPages}</span>
<button type="button" class="page-button" disabled={page === totalPages} onclick={() => (page = Math.min(totalPages, page + 1))}>
<span>Next</span>
<ChevronRight size={16} strokeWidth={2.2} />
</button>
</div>
</div>
{/if}
</div>
<style>
.history-shell {
display: grid;
gap: 0.95rem;
}
.log-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.log-title {
display: grid;
gap: 0.2rem;
}
.history-title {
display: inline-flex;
align-items: center;
gap: 0.55rem;
margin: 0;
font-size: 1.16rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.log-subtitle {
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.find-button,
.apply-button,
.clear-button,
.retry-button,
.sort-head,
.page-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
cursor: pointer;
}
.find-button {
position: relative;
min-height: 44px;
padding: 0.6rem 0.95rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-border));
border-radius: 0.78rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: 0.95rem;
font-weight: 650;
}
.find-button.active {
border-color: var(--color-brand);
background: var(--color-brand-tint);
color: var(--color-success);
}
.find-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-brand);
}
.filters {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)) auto;
gap: 0.8rem;
align-items: end;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 0.95rem;
background: color-mix(in srgb, var(--color-bg-surface) 82%, white);
}
.filters label {
display: grid;
gap: 0.32rem;
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.filters input,
.filters select {
min-height: 44px;
padding: 0.58rem 0.72rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 12%, var(--color-border));
border-radius: 0.78rem;
font-size: 0.97rem;
color: var(--color-text-primary);
background: var(--color-bg-surface);
}
.filter-actions {
display: inline-flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.apply-button,
.clear-button,
.retry-button,
.page-button {
min-height: 42px;
padding: 0.55rem 0.82rem;
border: 1px solid var(--color-border);
border-radius: 0.72rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: 0.92rem;
font-weight: 650;
}
.apply-button {
border-color: var(--color-brand);
background: var(--color-brand);
color: #fff;
}
.clear-button {
color: var(--color-text-secondary);
}
.error {
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
padding: 0.95rem 1rem;
border: 1px solid #efc6c2;
border-radius: 0.9rem;
background: #fff4f2;
color: #8a1622;
}
.log {
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-bg-surface);
overflow: clip;
}
.log-head,
.row {
display: grid;
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 10.5rem 4.8rem;
gap: 0.85rem;
align-items: center;
}
.log-head {
padding: 0.9rem 1.45rem;
border-bottom: 1px solid var(--color-divider);
background: color-mix(in srgb, var(--color-bg-app) 55%, var(--color-bg-surface));
}
.sort-head {
justify-content: flex-start;
padding: 0;
border: 0;
background: none;
color: var(--color-text-secondary);
font-size: 0.8rem;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sort-head.active,
.sort-head:hover {
color: var(--color-text-primary);
}
.row {
padding: 1rem 1.45rem;
border-bottom: 1px solid var(--color-divider);
transition: background-color 160ms ease;
}
.row:last-child {
border-bottom: 0;
}
.row:hover {
background: #fafbfc;
}
.row.just-added {
animation: flash-in 1.8s ease-out;
}
@keyframes flash-in {
0% { background: var(--color-brand-tint); }
100% { background: transparent; }
}
.col-product,
.col-dest,
.col-packed,
.col-total {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
.product-name,
.packed-main,
.total-kg {
font-weight: 650;
}
.dest-detail,
.packed-detail {
font-size: 0.88rem;
color: var(--color-text-secondary);
}
.total-kg,
.packed-main,
.dest-detail {
font-variant-numeric: tabular-nums;
}
.col-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.3rem;
}
.col-actions-head {
justify-self: end;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.row-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 0.55rem;
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease;
}
.row-action:hover {
border-color: var(--color-text-muted);
color: var(--color-text-primary);
background: #fafbfc;
}
.row-action-danger:hover {
border-color: #e2a8af;
color: #b3261e;
background: #fdecee;
}
.row-action:disabled,
.page-button:disabled,
.apply-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.row-notes {
grid-column: 1 / -1;
margin: 0.35rem 0 0;
padding-top: 0.65rem;
border-top: 1px dashed var(--color-divider);
font-size: 0.94rem;
color: var(--color-text-secondary);
}
.cell-label {
display: none;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-muted);
}
.col-notes-head {
display: none;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 650;
white-space: nowrap;
}
.pill-stock {
background: #e8f1fc;
color: #0b5cad;
}
.pill-order {
background: var(--color-brand-tint);
color: var(--color-success);
}
.pill-split {
background: #f3e8fc;
color: #6b21a8;
}
.row-skeleton {
padding: 1.15rem 1.45rem;
border-bottom: 1px solid var(--color-divider);
}
.sk {
height: 1.1rem;
border-radius: 0.4rem;
background: linear-gradient(90deg, #eef1f4 25%, #f6f8fa 50%, #eef1f4 75%);
background-size: 200% 100%;
animation: shimmer 1.3s ease-in-out infinite;
}
.sk-staff { width: 70%; }
.sk-qa { width: 6rem; height: 1.7rem; border-radius: 999px; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
padding: 3rem 1.5rem;
text-align: center;
}
.empty-title {
margin: 0;
font-size: 1.3rem;
font-weight: 650;
color: var(--color-text-primary);
}
.empty-help {
margin: 0;
font-size: 1.02rem;
color: var(--color-text-secondary);
max-width: 42ch;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 1rem 1.45rem 1.15rem;
border-top: 1px solid var(--color-divider);
background: color-mix(in srgb, var(--color-bg-app) 48%, var(--color-bg-surface));
}
.pagination-summary,
.page-indicator {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.94rem;
font-weight: 600;
}
.pagination-actions {
display: inline-flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
@media (max-width: 760px) {
.log-controls,
.filters,
.error {
padding-left: 1.15rem;
padding-right: 1.15rem;
}
.history-title {
font-size: 1.28rem;
}
.log-head {
display: none;
}
.row {
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1rem;
padding: 1rem 1.15rem;
}
.col-product,
.row-notes {
grid-column: 1 / -1;
}
.cell-label {
display: block;
margin-bottom: 0.1rem;
}
.col-actions {
grid-column: 1 / -1;
justify-content: flex-start;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--color-divider);
}
.row-action {
width: 2.5rem;
height: 2.5rem;
}
.pagination {
padding-left: 1.15rem;
padding-right: 1.15rem;
}
}
@media (min-width: 1280px) {
.log-head,
.row {
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 10.5rem minmax(0, 1.2fr) 4.8rem;
}
.col-notes-head {
display: block;
}
.row-notes {
grid-column: 7;
align-self: center;
margin: 0;
padding-top: 0;
border-top: 0;
font-size: 0.95rem;
}
.col-actions {
grid-column: 8;
}
}
@media (prefers-reduced-motion: reduce) {
.row.just-added,
.sk {
animation: none;
}
}
</style>
@@ -0,0 +1,120 @@
<script lang="ts">
import { CheckCircle2 } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import type { ConfettiPiece } from '$lib/components/throughput/utils';
let { confetti }: { confetti: ConfettiPiece[] } = $props();
</script>
<div class="success-overlay" role="presentation" transition:fade={{ duration: 160 }}>
<div class="success-card" role="status" aria-live="polite">
<div class="confetti" aria-hidden="true">
{#each confetti as piece, i (i)}
<span
class="confetti-piece"
style="left:{piece.left}%; width:{piece.size}px; height:{piece.size}px; background:{piece.color}; animation-delay:{piece.delay}s; animation-duration:{piece.duration}s; --rot:{piece.rotate}deg; --drift:{piece.drift}px;"
></span>
{/each}
</div>
<span class="success-icon"><CheckCircle2 size={44} strokeWidth={2.4} /></span>
<p class="success-title">Added</p>
<p class="success-text">Your packing run has been added to the log.</p>
</div>
</div>
<style>
.success-overlay {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
pointer-events: none;
overflow: hidden;
}
.success-card {
position: relative;
overflow: hidden;
width: min(30rem, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.7rem;
padding: 2.6rem 2.8rem 2.8rem;
border: 1px solid var(--color-border);
border-radius: 1.25rem;
background: var(--color-bg-surface);
box-shadow: 0 28px 70px -18px rgba(0, 0, 0, 0.45);
text-align: center;
animation: success-pop 240ms cubic-bezier(0.18, 0.89, 0.32, 1.28);
}
.success-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4.2rem;
height: 4.2rem;
border-radius: 50%;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.success-title,
.success-text {
margin: 0;
}
.success-title {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text-primary);
}
.success-text {
font-size: 1rem;
line-height: 1.45;
color: var(--color-text-secondary);
}
.confetti {
position: absolute;
inset: 0;
pointer-events: none;
}
.confetti-piece {
position: absolute;
top: -12%;
display: block;
border-radius: 2px;
opacity: 0;
animation-name: confetti-fall;
animation-timing-function: ease-in;
animation-iteration-count: infinite;
}
@keyframes success-pop {
from { opacity: 0; transform: scale(0.82); }
to { opacity: 1; transform: scale(1); }
}
@keyframes confetti-fall {
0% { transform: translate(0, 0) rotate(0deg); opacity: 0; }
12% { opacity: 1; }
100% { transform: translate(var(--drift), 340px) rotate(var(--rot)); opacity: 0.9; }
}
@media (prefers-reduced-motion: reduce) {
.success-card {
animation: none;
}
.confetti {
display: none;
}
}
</style>
@@ -0,0 +1,256 @@
<script lang="ts">
import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte';
import { MIX_RANGES } from '$lib/components/throughput/utils';
let {
today,
weekRangeLabel,
heroStats,
mixTotals,
mixRangeKey = $bindable<(typeof MIX_RANGES)[number]['key']>('4w'),
formatDate,
formatNumber
}: {
today: string;
weekRangeLabel: string;
heroStats: { today: number; thisWeek: number; avgFourWeek: number };
mixTotals: { horse: number; grain: number };
mixRangeKey?: (typeof MIX_RANGES)[number]['key'];
formatDate: (value: string) => string;
formatNumber: (value: number | null | undefined, digits?: number) => string;
} = $props();
</script>
<header class="throughput-summary" aria-label="Throughput summary">
<div class="summary-heading">
<span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span>
<h2>Throughput Overview</h2>
<div class="range-select" role="group" aria-label="Customer mix date range">
{#each MIX_RANGES as range (range.key)}
<button
type="button"
class="range-option"
class:active={mixRangeKey === range.key}
aria-pressed={mixRangeKey === range.key}
onclick={() => (mixRangeKey = range.key)}
>{range.label}</button>
{/each}
</div>
</div>
<dl class="facts">
<div class="fact">
<dt><span class="fact-icon"><CalendarDays size={16} strokeWidth={2.2} /></span>Today</dt>
<dd>{formatNumber(heroStats.today)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">{formatDate(today)}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><CalendarRange size={16} strokeWidth={2.2} /></span>This week</dt>
<dd>{formatNumber(heroStats.thisWeek)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">{weekRangeLabel}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><TrendingUp size={16} strokeWidth={2.2} /></span>4-week average</dt>
<dd>{formatNumber(heroStats.avgFourWeek)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">Per week, last 4 weeks</p>
</div>
</dl>
<dl class="mix-facts" aria-label="Throughput by customer">
<div class="fact">
<dt><span class="fact-icon"><Carrot size={16} strokeWidth={2.2} /></span>Horse Mix</dt>
<dd>{formatNumber(mixTotals.horse)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">PHF Horsemix · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><Wheat size={16} strokeWidth={2.2} /></span>Grain Mix</dt>
<dd>{formatNumber(mixTotals.grain)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">All other customers · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
</div>
</dl>
</header>
<style>
.throughput-summary {
display: grid;
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-border));
border-radius: 0.95rem;
background:
radial-gradient(circle at top right, color-mix(in srgb, var(--color-brand) 12%, transparent), transparent 45%),
linear-gradient(180deg, color-mix(in srgb, var(--color-brand) 5%, var(--color-bg-surface)), var(--color-bg-surface));
}
.summary-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem 0.75rem;
padding: 1rem 1rem 0;
}
.summary-heading h2 {
margin: 0;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.summary-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 0.65rem;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.range-select {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: auto;
padding: 0.28rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.8rem;
background: color-mix(in srgb, var(--color-bg-surface) 55%, transparent);
}
.range-option {
padding: 0.5rem 0.95rem;
border: 0;
border-radius: 0.6rem;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.95rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.range-option:hover {
color: var(--color-text-primary);
}
.range-option.active {
background: var(--color-brand);
color: #fff;
box-shadow: 0 8px 18px -14px color-mix(in srgb, var(--color-brand) 85%, transparent);
}
.facts,
.mix-facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
padding: 1rem;
}
.mix-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding-top: 0;
}
.fact {
container-type: inline-size;
position: relative;
display: grid;
gap: 0.28rem;
min-height: 7rem;
padding: 1rem 1rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 12%, var(--color-border));
border-radius: 0.95rem;
background: color-mix(in srgb, var(--color-bg-surface) 84%, white);
box-shadow: 0 12px 36px -28px rgba(15, 23, 42, 0.28);
}
.fact dt {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin: 0;
font-size: 0.83rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.fact-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.7rem;
height: 1.7rem;
border-radius: 0.6rem;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.fact dd {
margin: 0;
font-size: clamp(1.85rem, 4.5cqi, 2.3rem);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.03em;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
.fact-unit {
font-size: 0.45em;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.fact-sub {
margin: 0.05rem 0 0;
color: var(--color-text-secondary);
font-size: 0.88rem;
line-height: 1.35;
}
@media (min-width: 1024px) {
.throughput-summary {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.75rem;
padding: 1rem;
}
.facts,
.mix-facts {
display: contents;
}
.summary-heading {
grid-column: 1 / -1;
padding: 0;
}
.fact {
min-height: 7rem;
padding: 0.9rem 0.85rem;
}
.fact-sub {
font-size: 0.78rem;
}
}
@media (max-width: 760px) {
.facts {
grid-template-columns: 1fr;
padding: 0.9rem;
}
.mix-facts {
grid-template-columns: 1fr;
padding: 0 0.9rem 0.9rem;
}
}
</style>
@@ -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 ?? ''));
}
+3 -3
View File
@@ -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()}
</CustomerPortalShell>
{:else}
<ClientShell>
<AppShell>
{@render children()}
</ClientShell>
</AppShell>
{/if}
<Toast />
File diff suppressed because it is too large Load Diff