Release v0.1.23
This commit is contained in:
Generated
+2
-2
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 ?? ''));
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user