v0.1.27
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user