Release v0.1.23

This commit is contained in:
2026-06-15 11:59:04 +12:00
parent 250d6ab6a9
commit 8f9a7b8193
18 changed files with 3725 additions and 3298 deletions
+606
View File
@@ -0,0 +1,606 @@
<script lang="ts">
import { api } from '$lib/api';
import AuthGate from '$lib/components/AuthGate.svelte';
import WorkspaceBootCard from '$lib/components/app-shell/WorkspaceBootCard.svelte';
import WorkspaceQuickAccess from '$lib/components/app-shell/WorkspaceQuickAccess.svelte';
import WorkspaceSearchPalette from '$lib/components/app-shell/WorkspaceSearchPalette.svelte';
import WorkspaceSignedOutCard from '$lib/components/app-shell/WorkspaceSignedOutCard.svelte';
import WorkspaceTabletNav from '$lib/components/app-shell/WorkspaceTabletNav.svelte';
import { PALETTE_RESULT_LIMIT, buildSessionKey, filterSearchItems } from '$lib/components/app-shell/utils';
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
import { currentChangelog } from '$lib/changelog';
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
import { featureFlags } from '$lib/features';
import {
canCreateMixSession as sessionCanCreateMixSession,
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenDashboard as sessionCanOpenDashboard,
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster,
canOpenCustomerOrdering as sessionCanOpenCustomerOrdering,
canManageOrdering as sessionCanManageOrdering,
canOpenProductCosting as sessionCanOpenProductCosting,
canOpenReporting as sessionCanOpenReporting,
canOpenSettings as sessionCanOpenSettings,
canOpenThroughput as sessionCanOpenThroughput,
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
getWorkspaceRole,
getWorkspaceHomeHref as sessionWorkspaceHomeHref,
isWorkspaceRouteAllowed
} from '$lib/workspace-access';
import {
baseSearchItems,
buildClientNavEntries,
clientBreadcrumbs,
dashboardItem,
editorItem,
ingredientsEditorItem,
footerLinks,
mixCalculatorItem,
orderingItem,
orderingManageChildren,
orderingManageGroup,
pageTitle,
productCostingItem,
reportingItem,
throughputItem,
type FooterLink,
type NavEntry,
type SearchItem,
type NavItem,
workingDocumentItems
} from '$lib/navigation/client-navigation';
import { onMount } from 'svelte';
import packageInfo from '../../../package.json';
let { children } = $props();
const isRootRoute = $derived(page.url.pathname === '/');
let paletteOpen = $state(false);
let paletteQuery = $state('');
let quickMenuOpen = $state(false);
let userMenuOpen = $state(false);
let navOpen = $state(false);
let showBottomNav = $state(false);
let whatsNewOpen = $state(false);
// The user identity we've already run the "what's new" check for this mount,
// so the dialog is evaluated once per login rather than on every navigation.
let whatsNewCheckedFor = $state<string | null>(null);
let isRestoringSession = $state(false);
let restoredSessionKey = $state<string | null>(null);
let seededSearchItems = $state<SearchItem[]>([]);
let seededSearchKey = $state<string | null>(null);
let bootDelayDone = $state(false);
const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear();
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
const shellTitle = $derived(routeGuardPending ? 'Loading Workspace' : pageTitle(page.url.pathname));
const shellBreadcrumbs = $derived(
routeGuardPending ? clientBreadcrumbs(workspaceHomeHref, $clientSession) : clientBreadcrumbs(page.url.pathname, $clientSession)
);
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
const visibleWorkingDocumentItems = $derived(
!$clientSession
? workingDocumentItems
: workingDocumentItems.filter((item) => {
if (item.href === '/mixes') return canOpenMixMaster;
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
})
);
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
// Ordering serves two audiences: internal staff get the management console
// (/ordering/manage), customers get the catalogue (/ordering).
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
// Internal staff get the collapsible "Order Management" family (queue +
// products/customers/pricing/settings/integrations); customers get the single
// catalogue link. Either way it becomes one NavEntry the rail can render.
const visibleOrderingEntry = $derived<NavEntry | null>(
canManageOrdering
? { kind: 'group', group: orderingManageGroup }
: canOpenCustomerOrdering
? { kind: 'item', item: orderingItem }
: null
);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the
// standalone operations/insights modules. Built from the same access-filtered
// items, so a role only ever sees the families it may open.
const navEntries = $derived(
buildClientNavEntries({
dashboard: visibleDashboardItem,
costing: [
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
...visibleWorkingDocumentItems
],
throughput: visibleThroughputItem,
ordering: visibleOrderingEntry,
reporting: visibleReportingItem
})
);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([
...(!isOperationsUser ? footerLinks : [])
] as FooterLink[]);
const primaryBottomNavigation = $derived(
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...visibleWorkingDocumentItems.slice(0, 2)
]
);
const workingDocumentsActive = $derived(
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
);
const visibleBaseSearchItems = $derived(
baseSearchItems.filter((item) => {
if (item.href === '/') return canOpenDashboard;
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/mixes/new') return canCreateMixWorksheet;
if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession);
if (item.href === '/editor') return canOpenEditor;
if (item.href === '/ingredients') return canOpenEditor;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings;
return true;
})
);
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone));
function openPalette(query = '') {
paletteQuery = query;
paletteOpen = true;
quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
}
function syncViewport() {
showBottomNav = window.innerWidth <= 1180;
if (!showBottomNav) {
navOpen = false;
}
}
async function runSearchItem(item: SearchItem) {
paletteOpen = false;
paletteQuery = '';
await goto(item.href);
}
async function openSettings() {
quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
await goto('/settings');
}
async function signOut() {
try {
if ($clientSession?.role === 'internal') {
await api.internalLogout();
} else {
await api.clientLogout();
}
} catch {
// Clearing the local session remains the safe fallback.
} finally {
clientSession.clear();
}
}
const paletteState = $derived(filterSearchItems(searchItems, paletteQuery, PALETTE_RESULT_LIMIT));
$effect(() => {
page.url.pathname;
quickMenuOpen = false;
userMenuOpen = false;
paletteOpen = false;
paletteQuery = '';
navOpen = false;
});
$effect(() => {
const hydrated = $sessionHydrated;
const sessionKey = buildSessionKey($clientSession);
if (!hydrated) {
return;
}
if (!sessionKey) {
isRestoringSession = false;
restoredSessionKey = null;
return;
}
if (restoredSessionKey === sessionKey) {
return;
}
restoredSessionKey = sessionKey;
isRestoringSession = true;
// Internal Hunter Stock Feeds users are refreshed against /api/access/me;
// legacy client-portal users keep using /api/auth/client/session.
const refresh = $clientSession?.role === 'internal' ? api.internalSession() : api.clientSession();
refresh
.then((session) => {
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredSessionKey = null;
clientSession.clear();
})
.finally(() => {
isRestoringSession = false;
});
});
// Search palette items are seeded lazily — three list endpoints worth of
// data only when the user actually opens the palette, not on every login or
// navigation. Subsequent opens hit the api.ts cache.
$effect(() => {
const hydrated = $sessionHydrated;
const session = $clientSession;
const sessionKey = buildSessionKey(session);
const shouldSeed = paletteOpen;
if (!hydrated || !session || !sessionKey) {
seededSearchItems = [];
seededSearchKey = null;
return;
}
if (!shouldSeed || seededSearchKey === sessionKey) {
return;
}
seededSearchKey = sessionKey;
Promise.all([
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
? api.mixCalculatorSessions()
: Promise.resolve([])
])
.then(([mixes, sessions]) => {
if (seededSearchKey !== sessionKey) {
return;
}
seededSearchItems = [
...mixes.map((mix) => ({
href: `/mixes/${mix.id}`,
label: mix.name,
description: `Mix · ${mix.client_name} · ${mix.total_mix_kg}kg`,
keywords: `mix ${mix.name} ${mix.client_name} ${mix.notes ?? ''} ${mix.ingredients.map((ingredient) => ingredient.raw_material_name).join(' ')}`
})),
...sessions.map((savedSession) => ({
href: `/mix-calculator/${savedSession.id}`,
label: `${savedSession.session_number} · ${savedSession.product_name}`,
description: `Mix Session · ${savedSession.prepared_by_name} · ${savedSession.mix_date}`,
keywords: `mix calculator session ${savedSession.session_number} ${savedSession.product_name} ${savedSession.mix_name} ${savedSession.client_name} ${savedSession.prepared_by_name} ${savedSession.notes ?? ''}`
}))
];
})
.catch(() => {
if (seededSearchKey === sessionKey) {
seededSearchItems = [];
}
});
});
$effect(() => {
if ($sessionHydrated && !$clientSession && !isRootRoute) {
goto('/', { replaceState: true });
}
});
$effect(() => {
if (!$sessionHydrated || !$clientSession) {
return;
}
if (currentRouteAllowed || page.url.pathname === workspaceHomeHref) {
return;
}
goto(workspaceHomeHref, { replaceState: true });
});
// Surface the release notes once per version per user, right after login.
// hasSeenVersion keeps this to a single appearance: once dismissed (which
// records the version), it won't return until the next version ships.
$effect(() => {
if (!$sessionHydrated || !$clientSession || !currentChangelog) {
return;
}
const userKey = buildSessionKey($clientSession);
if (!userKey) {
return;
}
if (whatsNewCheckedFor === userKey) {
return;
}
whatsNewCheckedFor = userKey;
if (!hasSeenVersion(userKey, currentChangelog.version)) {
whatsNewOpen = true;
}
});
function dismissWhatsNew() {
const userKey = buildSessionKey($clientSession);
if (userKey && currentChangelog) {
markVersionSeen(userKey, currentChangelog.version);
}
whatsNewOpen = false;
}
onMount(() => {
const bootTimer = window.setTimeout(() => {
bootDelayDone = true;
}, 1500);
syncViewport();
const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const isTypingField =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target?.isContentEditable;
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
event.preventDefault();
openPalette();
}
if (event.key === 'Escape') {
paletteOpen = false;
quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
}
};
window.addEventListener('keydown', handleKeydown);
window.addEventListener('resize', syncViewport);
return () => {
window.clearTimeout(bootTimer);
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('resize', syncViewport);
};
});
const userInitials = $derived(
($clientSession?.name ?? '')
.split(' ')
.slice(0, 2)
.map((w: string) => w[0])
.join('')
.toUpperCase() || '?'
);
</script>
<svelte:head>
<title>{shellTitle} | Hunter Premium Produce</title>
</svelte:head>
{#if !$clientSession}
<div class="signed-out-shell">
{#if isRootRoute}
{@render children()}
{:else if showWorkspaceBoot}
<WorkspaceBootCard />
{:else}
<WorkspaceSignedOutCard />
{/if}
</div>
{:else}
<div class="app-shell">
{#if !showBottomNav}
<ClientPrimaryRail
currentPath={shellPathname}
entries={navEntries}
brandHref={workspaceHomeHref}
footerItems={visibleFooterLinks}
{appVersion}
{currentYear}
{canOpenSettings}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
{/if}
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
<ClientTopbar
breadcrumbs={shellBreadcrumbs}
title={shellTitle}
sessionHydrated={$sessionHydrated}
session={$clientSession}
{userInitials}
{userMenuOpen}
{canUseWorkspaceSearch}
{canOpenSettings}
onOpenPalette={() => canUseWorkspaceSearch && openPalette()}
onToggleUserMenu={() => {
userMenuOpen = !userMenuOpen;
quickMenuOpen = false;
}}
onOpenSettings={openSettings}
onSignOut={signOut}
onShowWhatsNew={() => (whatsNewOpen = true)}
/>
<main class="content">
<AuthGate
blocked={routeGuardPending}
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
title={isRestoringSession ? 'Restoring your client workspace.' : 'Routing you to an authorised page.'}
detail={
isRestoringSession
? 'Refreshing the saved session before rendering workspace content.'
: `The ${workspaceRole} role cannot open this route, so the workspace is redirecting before any page content mounts.`
}
>
{@render children()}
</AuthGate>
</main>
</div>
<WorkspaceQuickAccess
bind:quickMenuOpen
{canOpenMixMaster}
{canCreateMixWorksheet}
{canOpenMixCalculator}
{canCreateMixSession}
{canUseWorkspaceSearch}
onOpenPalette={() => openPalette('')}
/>
</div>
<WorkspaceTabletNav
bind:navOpen
{showBottomNav}
{primaryBottomNavigation}
{visibleDashboardItem}
{visibleMixCalculatorItem}
{visibleProductCostingItem}
{visibleThroughputItem}
{visibleEditorItem}
{visibleReportingItem}
{visibleWorkingDocumentItems}
{visibleFooterLinks}
{orderingManageGroup}
{orderingManageChildren}
{orderingItem}
{canManageOrdering}
{canOpenCustomerOrdering}
{canCreateMixWorksheet}
{canCreateMixSession}
{canOpenSettings}
{canUseWorkspaceSearch}
pagePath={page.url.pathname}
onOpenPalette={() => openPalette('')}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
{/if}
{#if $clientSession && whatsNewOpen && currentChangelog}
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
{/if}
{#if $clientSession && paletteOpen}
<WorkspaceSearchPalette
bind:query={paletteQuery}
filteredSearchItems={paletteState.filteredItems}
hiddenResultCount={paletteState.hiddenResultCount}
onClose={() => (paletteOpen = false)}
onRunSearchItem={runSearchItem}
/>
{/if}
<style>
.app-shell {
display: grid;
grid-template-columns: 252px minmax(0, 1fr);
min-height: 100vh;
background: var(--color-bg-app);
}
.signed-out-shell {
min-height: 100vh;
padding: 1.5rem;
}
.main-shell {
min-width: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
overflow: hidden;
background: var(--color-bg-app);
}
.content {
--content-padding: 1.34rem;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
padding: var(--content-padding);
overflow: auto;
background: var(--color-bg-app);
}
.main-shell.bottom-nav-layout .content {
padding-bottom: 7.25rem;
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 1fr;
}
.content {
--content-padding: 1rem;
padding: 1rem;
}
}
@media (min-width: 1181px) {
.bottom-nav-layout .content {
padding-bottom: 1.34rem;
}
}
@media (max-width: 720px) {
.content {
--content-padding: 0.92rem;
padding: 0.92rem;
}
}
</style>