Release v0.1.23
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.22",
|
"version": "0.1.23",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.22",
|
"version": "0.1.23",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"lucide-svelte": "^1.0.1"
|
"lucide-svelte": "^1.0.1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.22",
|
"version": "0.1.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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 '$lib/theme';
|
||||||
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
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 CustomerPortalShell from '$lib/components/CustomerPortalShell.svelte';
|
||||||
import Toast from '$lib/components/Toast.svelte';
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
import { clientSession } from '$lib/session';
|
import { clientSession } from '$lib/session';
|
||||||
@@ -50,9 +50,9 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
</CustomerPortalShell>
|
</CustomerPortalShell>
|
||||||
{:else}
|
{:else}
|
||||||
<ClientShell>
|
<AppShell>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</ClientShell>
|
</AppShell>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user