Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live.
Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows
Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator).
Fix: Security headers on all API responses (hardening)
Add: New mix button available on the Mix Editor.
Add: New ingredient button available on the Ingredient Editor
This commit is contained in:
2026-06-16 14:43:17 +12:00
parent 8f9a7b8193
commit 7db95e2027
46 changed files with 3805 additions and 1049 deletions
+133 -58
View File
@@ -2,13 +2,13 @@
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 WorkspaceAppsFab, { type WorkspaceFabItem } from '$lib/components/WorkspaceAppsFab.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 WorkspacePageHeader from '$lib/components/navigation/WorkspacePageHeader.svelte';
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
import { currentChangelog } from '$lib/changelog';
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
@@ -20,6 +20,7 @@
import {
canCreateMixSession as sessionCanCreateMixSession,
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenClientAccess as sessionCanOpenClientAccess,
canOpenDashboard as sessionCanOpenDashboard,
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
@@ -36,9 +37,9 @@
isWorkspaceRouteAllowed
} from '$lib/workspace-access';
import {
accessControlItem,
baseSearchItems,
buildClientNavEntries,
clientBreadcrumbs,
dashboardItem,
editorItem,
ingredientsEditorItem,
@@ -47,7 +48,7 @@
orderingItem,
orderingManageChildren,
orderingManageGroup,
pageTitle,
pageMeta,
productCostingItem,
reportingItem,
throughputItem,
@@ -63,9 +64,11 @@
let { children } = $props();
const isRootRoute = $derived(page.url.pathname === '/');
let paletteOpen = $state(false);
let paletteQuery = $state('');
let quickMenuOpen = $state(false);
let searchOpen = $state(false);
let searchQuery = $state('');
let searchFocusRequest = $state(0);
let appsFabOpen = $state(false);
let sidebarOpen = $state(true);
let userMenuOpen = $state(false);
let navOpen = $state(false);
let showBottomNav = $state(false);
@@ -78,6 +81,8 @@
let seededSearchItems = $state<SearchItem[]>([]);
let seededSearchKey = $state<string | null>(null);
let bootDelayDone = $state(false);
let sidebarStateReady = $state(false);
const SIDEBAR_STORAGE_KEY = 'hsf:shell:sidebar-open';
const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear();
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
@@ -92,9 +97,10 @@
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 shellPageMeta = $derived(
routeGuardPending
? { title: 'Loading Workspace', category: 'Workspace', icon: dashboardItem.icon }
: pageMeta(page.url.pathname)
);
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
const visibleWorkingDocumentItems = $derived(
@@ -126,9 +132,11 @@
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 visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (costing
// tools plus throughput), then the standalone ordering/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,
@@ -149,6 +157,37 @@
const visibleFooterLinks = $derived([
...(!isOperationsUser ? footerLinks : [])
] as FooterLink[]);
const fabItems = $derived.by(() => {
const items = [
visibleDashboardItem,
...visibleWorkingDocumentItems,
visibleMixCalculatorItem,
visibleProductCostingItem,
visibleThroughputItem,
visibleOrderingEntry?.kind === 'item'
? visibleOrderingEntry.item
: visibleOrderingEntry?.group
? {
href: visibleOrderingEntry.group.href ?? '/ordering/manage',
label: visibleOrderingEntry.group.label,
icon: visibleOrderingEntry.group.icon
}
: null,
visibleReportingItem,
visibleEditorItem,
visibleIngredientsEditorItem,
visibleAccessControlItem
].filter((item): item is { href: string; label: string; icon: WorkspaceFabItem['icon'] } => Boolean(item));
const seen = new Set<string>();
return items.flatMap((item) => {
if (seen.has(item.href)) {
return [];
}
seen.add(item.href);
return [{ href: item.href, label: item.label, icon: item.icon } satisfies WorkspaceFabItem];
});
});
const primaryBottomNavigation = $derived(
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
@@ -176,11 +215,37 @@
);
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone));
const showDesktopSidebar = $derived(!showBottomNav);
function openPalette(query = '') {
paletteQuery = query;
paletteOpen = true;
quickMenuOpen = false;
function restoreSidebarState() {
if (typeof window === 'undefined') {
return true;
}
try {
return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) !== 'false';
} catch {
return true;
}
}
function persistSidebarState() {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarOpen));
} catch {
// Storage failures should not block shell interactions.
}
}
function openSearch(query = '') {
searchQuery = query;
searchOpen = true;
searchFocusRequest += 1;
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
}
@@ -194,13 +259,13 @@
}
async function runSearchItem(item: SearchItem) {
paletteOpen = false;
paletteQuery = '';
searchOpen = false;
searchQuery = '';
await goto(item.href);
}
async function openSettings() {
quickMenuOpen = false;
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
await goto('/settings');
@@ -220,17 +285,26 @@
}
}
const paletteState = $derived(filterSearchItems(searchItems, paletteQuery, PALETTE_RESULT_LIMIT));
const searchState = $derived(filterSearchItems(searchItems, searchQuery, PALETTE_RESULT_LIMIT));
$effect(() => {
page.url.pathname;
quickMenuOpen = false;
appsFabOpen = false;
userMenuOpen = false;
paletteOpen = false;
paletteQuery = '';
searchOpen = false;
searchQuery = '';
navOpen = false;
});
$effect(() => {
if (!sidebarStateReady) {
return;
}
sidebarOpen;
persistSidebarState();
});
$effect(() => {
const hydrated = $sessionHydrated;
const sessionKey = buildSessionKey($clientSession);
@@ -271,14 +345,14 @@
});
});
// 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
// Search items are seeded lazily — three list endpoints worth of
// data only when the user actually opens the search, 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;
const shouldSeed = searchOpen;
if (!hydrated || !session || !sessionKey) {
seededSearchItems = [];
@@ -374,6 +448,9 @@
}
onMount(() => {
sidebarOpen = restoreSidebarState();
sidebarStateReady = true;
const bootTimer = window.setTimeout(() => {
bootDelayDone = true;
}, 1500);
@@ -390,12 +467,12 @@
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
event.preventDefault();
openPalette();
openSearch();
}
if (event.key === 'Escape') {
paletteOpen = false;
quickMenuOpen = false;
searchOpen = false;
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
}
@@ -422,7 +499,7 @@
</script>
<svelte:head>
<title>{shellTitle} | Hunter Premium Produce</title>
<title>{shellPageMeta.title} | Hunter Premium Produce</title>
</svelte:head>
{#if !$clientSession}
@@ -436,9 +513,10 @@
{/if}
</div>
{:else}
<div class="app-shell">
{#if !showBottomNav}
<div class:sidebar-collapsed={!sidebarOpen && showDesktopSidebar} class="app-shell">
{#if showDesktopSidebar}
<ClientPrimaryRail
collapsed={!sidebarOpen}
currentPath={shellPathname}
entries={navEntries}
brandHref={workspaceHomeHref}
@@ -453,18 +531,24 @@
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
<ClientTopbar
breadcrumbs={shellBreadcrumbs}
title={shellTitle}
sessionHydrated={$sessionHydrated}
session={$clientSession}
showSidebarToggle={!showBottomNav}
{sidebarOpen}
{userInitials}
{userMenuOpen}
{canUseWorkspaceSearch}
bind:searchQuery={searchQuery}
bind:searchOpen={searchOpen}
searchFocusRequest={searchFocusRequest}
filteredSearchItems={searchState.filteredItems}
hiddenResultCount={searchState.hiddenResultCount}
{canOpenSettings}
onOpenPalette={() => canUseWorkspaceSearch && openPalette()}
onRunSearchItem={runSearchItem}
onToggleSidebar={() => (sidebarOpen = !sidebarOpen)}
onToggleUserMenu={() => {
userMenuOpen = !userMenuOpen;
quickMenuOpen = false;
appsFabOpen = false;
}}
onOpenSettings={openSettings}
onSignOut={signOut}
@@ -472,6 +556,13 @@
/>
<main class="content">
{#if !routeGuardPending}
<WorkspacePageHeader
category={shellPageMeta.category}
title={shellPageMeta.title}
icon={shellPageMeta.icon}
/>
{/if}
<AuthGate
blocked={routeGuardPending}
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
@@ -487,15 +578,7 @@
</main>
</div>
<WorkspaceQuickAccess
bind:quickMenuOpen
{canOpenMixMaster}
{canCreateMixWorksheet}
{canOpenMixCalculator}
{canCreateMixSession}
{canUseWorkspaceSearch}
onOpenPalette={() => openPalette('')}
/>
<WorkspaceAppsFab bind:open={appsFabOpen} items={fabItems} />
</div>
<WorkspaceTabletNav
@@ -518,9 +601,7 @@
{canCreateMixWorksheet}
{canCreateMixSession}
{canOpenSettings}
{canUseWorkspaceSearch}
pagePath={page.url.pathname}
onOpenPalette={() => openPalette('')}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
@@ -531,16 +612,6 @@
<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;
@@ -549,6 +620,10 @@
background: var(--color-bg-app);
}
.app-shell.sidebar-collapsed {
grid-template-columns: 4.5rem minmax(0, 1fr);
}
.signed-out-shell {
min-height: 100vh;
padding: 1.5rem;