2026-06-15 11:59:04 +12:00
|
|
|
<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 WorkspaceSignedOutCard from '$lib/components/app-shell/WorkspaceSignedOutCard.svelte';
|
|
|
|
|
import WorkspaceTabletNav from '$lib/components/app-shell/WorkspaceTabletNav.svelte';
|
2026-06-16 14:43:17 +12:00
|
|
|
import WorkspaceAppsFab, { type WorkspaceFabItem } from '$lib/components/WorkspaceAppsFab.svelte';
|
2026-06-15 11:59:04 +12:00
|
|
|
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';
|
2026-06-16 14:43:17 +12:00
|
|
|
import WorkspacePageHeader from '$lib/components/navigation/WorkspacePageHeader.svelte';
|
2026-06-15 11:59:04 +12:00
|
|
|
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,
|
2026-06-16 14:43:17 +12:00
|
|
|
canOpenClientAccess as sessionCanOpenClientAccess,
|
2026-06-15 11:59:04 +12:00
|
|
|
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 {
|
2026-06-16 14:43:17 +12:00
|
|
|
accessControlItem,
|
2026-06-15 11:59:04 +12:00
|
|
|
baseSearchItems,
|
|
|
|
|
buildClientNavEntries,
|
|
|
|
|
dashboardItem,
|
|
|
|
|
editorItem,
|
|
|
|
|
ingredientsEditorItem,
|
|
|
|
|
footerLinks,
|
|
|
|
|
mixCalculatorItem,
|
|
|
|
|
orderingItem,
|
|
|
|
|
orderingManageChildren,
|
|
|
|
|
orderingManageGroup,
|
2026-06-16 14:43:17 +12:00
|
|
|
pageMeta,
|
2026-06-15 11:59:04 +12:00
|
|
|
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 === '/');
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
let searchOpen = $state(false);
|
|
|
|
|
let searchQuery = $state('');
|
|
|
|
|
let searchFocusRequest = $state(0);
|
|
|
|
|
let appsFabOpen = $state(false);
|
|
|
|
|
let sidebarOpen = $state(true);
|
2026-06-15 11:59:04 +12:00
|
|
|
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);
|
2026-06-16 14:43:17 +12:00
|
|
|
let sidebarStateReady = $state(false);
|
|
|
|
|
const SIDEBAR_STORAGE_KEY = 'hsf:shell:sidebar-open';
|
2026-06-15 11:59:04 +12:00
|
|
|
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);
|
2026-06-16 14:43:17 +12:00
|
|
|
const shellPageMeta = $derived(
|
|
|
|
|
routeGuardPending
|
|
|
|
|
? { title: 'Loading Workspace', category: 'Workspace', icon: dashboardItem.icon }
|
|
|
|
|
: pageMeta(page.url.pathname)
|
2026-06-15 11:59:04 +12:00
|
|
|
);
|
|
|
|
|
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);
|
2026-06-16 14:43:17 +12:00
|
|
|
const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null);
|
2026-06-17 22:50:49 +12:00
|
|
|
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (mix
|
|
|
|
|
// calculator plus throughput), a "Costing" family (product costing and the
|
|
|
|
|
// editors), then the standalone ordering/insights modules. Built from the same
|
|
|
|
|
// access-filtered items, so a role only ever sees the families it may open.
|
2026-06-15 11:59:04 +12:00
|
|
|
const navEntries = $derived(
|
|
|
|
|
buildClientNavEntries({
|
|
|
|
|
dashboard: visibleDashboardItem,
|
2026-06-17 22:50:49 +12:00
|
|
|
operations: [
|
2026-06-15 11:59:04 +12:00
|
|
|
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
|
2026-06-17 22:50:49 +12:00
|
|
|
...(visibleThroughputItem ? [visibleThroughputItem] : [])
|
|
|
|
|
],
|
|
|
|
|
costing: [
|
2026-06-15 11:59:04 +12:00
|
|
|
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
|
|
|
|
|
...(visibleEditorItem ? [visibleEditorItem] : []),
|
|
|
|
|
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
|
|
|
|
|
...visibleWorkingDocumentItems
|
|
|
|
|
],
|
|
|
|
|
ordering: visibleOrderingEntry,
|
|
|
|
|
reporting: visibleReportingItem
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
|
|
|
|
|
const workspaceRole = $derived(getWorkspaceRole($clientSession));
|
|
|
|
|
const visibleFooterLinks = $derived([
|
|
|
|
|
...(!isOperationsUser ? footerLinks : [])
|
|
|
|
|
] as FooterLink[]);
|
2026-06-16 14:43:17 +12:00
|
|
|
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];
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-15 11:59:04 +12:00
|
|
|
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));
|
2026-06-16 14:43:17 +12:00
|
|
|
const showDesktopSidebar = $derived(!showBottomNav);
|
2026-06-15 11:59:04 +12:00
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
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;
|
2026-06-15 11:59:04 +12:00
|
|
|
userMenuOpen = false;
|
|
|
|
|
navOpen = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncViewport() {
|
|
|
|
|
showBottomNav = window.innerWidth <= 1180;
|
|
|
|
|
|
|
|
|
|
if (!showBottomNav) {
|
|
|
|
|
navOpen = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runSearchItem(item: SearchItem) {
|
2026-06-16 14:43:17 +12:00
|
|
|
searchOpen = false;
|
|
|
|
|
searchQuery = '';
|
2026-06-15 11:59:04 +12:00
|
|
|
await goto(item.href);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openSettings() {
|
2026-06-16 14:43:17 +12:00
|
|
|
appsFabOpen = false;
|
2026-06-15 11:59:04 +12:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
const searchState = $derived(filterSearchItems(searchItems, searchQuery, PALETTE_RESULT_LIMIT));
|
2026-06-15 11:59:04 +12:00
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
page.url.pathname;
|
2026-06-16 14:43:17 +12:00
|
|
|
appsFabOpen = false;
|
2026-06-15 11:59:04 +12:00
|
|
|
userMenuOpen = false;
|
2026-06-16 14:43:17 +12:00
|
|
|
searchOpen = false;
|
|
|
|
|
searchQuery = '';
|
2026-06-15 11:59:04 +12:00
|
|
|
navOpen = false;
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
$effect(() => {
|
|
|
|
|
if (!sidebarStateReady) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sidebarOpen;
|
|
|
|
|
persistSidebarState();
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-15 11:59:04 +12:00
|
|
|
$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;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
// Search items are seeded lazily — three list endpoints worth of
|
|
|
|
|
// data only when the user actually opens the search, not on every login or
|
2026-06-15 11:59:04 +12:00
|
|
|
// navigation. Subsequent opens hit the api.ts cache.
|
|
|
|
|
$effect(() => {
|
|
|
|
|
const hydrated = $sessionHydrated;
|
|
|
|
|
const session = $clientSession;
|
|
|
|
|
const sessionKey = buildSessionKey(session);
|
2026-06-16 14:43:17 +12:00
|
|
|
const shouldSeed = searchOpen;
|
2026-06-15 11:59:04 +12:00
|
|
|
|
|
|
|
|
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(() => {
|
2026-06-16 14:43:17 +12:00
|
|
|
sidebarOpen = restoreSidebarState();
|
|
|
|
|
sidebarStateReady = true;
|
|
|
|
|
|
2026-06-15 11:59:04 +12:00
|
|
|
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();
|
2026-06-16 14:43:17 +12:00
|
|
|
openSearch();
|
2026-06-15 11:59:04 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.key === 'Escape') {
|
2026-06-16 14:43:17 +12:00
|
|
|
searchOpen = false;
|
|
|
|
|
appsFabOpen = false;
|
2026-06-15 11:59:04 +12:00
|
|
|
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>
|
2026-06-16 14:43:17 +12:00
|
|
|
<title>{shellPageMeta.title} | Hunter Premium Produce</title>
|
2026-06-15 11:59:04 +12:00
|
|
|
</svelte:head>
|
|
|
|
|
|
|
|
|
|
{#if !$clientSession}
|
|
|
|
|
<div class="signed-out-shell">
|
|
|
|
|
{#if isRootRoute}
|
|
|
|
|
{@render children()}
|
|
|
|
|
{:else if showWorkspaceBoot}
|
|
|
|
|
<WorkspaceBootCard />
|
|
|
|
|
{:else}
|
|
|
|
|
<WorkspaceSignedOutCard />
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
2026-06-16 14:43:17 +12:00
|
|
|
<div class:sidebar-collapsed={!sidebarOpen && showDesktopSidebar} class="app-shell">
|
|
|
|
|
{#if showDesktopSidebar}
|
2026-06-15 11:59:04 +12:00
|
|
|
<ClientPrimaryRail
|
2026-06-16 14:43:17 +12:00
|
|
|
collapsed={!sidebarOpen}
|
2026-06-15 11:59:04 +12:00
|
|
|
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
|
|
|
|
|
sessionHydrated={$sessionHydrated}
|
|
|
|
|
session={$clientSession}
|
2026-06-16 14:43:17 +12:00
|
|
|
showSidebarToggle={!showBottomNav}
|
|
|
|
|
{sidebarOpen}
|
2026-06-15 11:59:04 +12:00
|
|
|
{userInitials}
|
|
|
|
|
{userMenuOpen}
|
|
|
|
|
{canUseWorkspaceSearch}
|
2026-06-16 14:43:17 +12:00
|
|
|
bind:searchQuery={searchQuery}
|
|
|
|
|
bind:searchOpen={searchOpen}
|
|
|
|
|
searchFocusRequest={searchFocusRequest}
|
|
|
|
|
filteredSearchItems={searchState.filteredItems}
|
|
|
|
|
hiddenResultCount={searchState.hiddenResultCount}
|
2026-06-15 11:59:04 +12:00
|
|
|
{canOpenSettings}
|
2026-06-16 14:43:17 +12:00
|
|
|
onRunSearchItem={runSearchItem}
|
|
|
|
|
onToggleSidebar={() => (sidebarOpen = !sidebarOpen)}
|
2026-06-15 11:59:04 +12:00
|
|
|
onToggleUserMenu={() => {
|
|
|
|
|
userMenuOpen = !userMenuOpen;
|
2026-06-16 14:43:17 +12:00
|
|
|
appsFabOpen = false;
|
2026-06-15 11:59:04 +12:00
|
|
|
}}
|
|
|
|
|
onOpenSettings={openSettings}
|
|
|
|
|
onSignOut={signOut}
|
|
|
|
|
onShowWhatsNew={() => (whatsNewOpen = true)}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<main class="content">
|
2026-06-16 14:43:17 +12:00
|
|
|
{#if !routeGuardPending}
|
|
|
|
|
<WorkspacePageHeader
|
|
|
|
|
category={shellPageMeta.category}
|
|
|
|
|
title={shellPageMeta.title}
|
|
|
|
|
icon={shellPageMeta.icon}
|
|
|
|
|
/>
|
|
|
|
|
{/if}
|
2026-06-15 11:59:04 +12:00
|
|
|
<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>
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
<WorkspaceAppsFab bind:open={appsFabOpen} items={fabItems} />
|
2026-06-15 11:59:04 +12:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<WorkspaceTabletNav
|
|
|
|
|
bind:navOpen
|
|
|
|
|
{showBottomNav}
|
|
|
|
|
{primaryBottomNavigation}
|
|
|
|
|
{visibleDashboardItem}
|
|
|
|
|
{visibleMixCalculatorItem}
|
|
|
|
|
{visibleProductCostingItem}
|
|
|
|
|
{visibleThroughputItem}
|
|
|
|
|
{visibleEditorItem}
|
|
|
|
|
{visibleReportingItem}
|
|
|
|
|
{visibleWorkingDocumentItems}
|
|
|
|
|
{visibleFooterLinks}
|
|
|
|
|
{orderingManageGroup}
|
|
|
|
|
{orderingManageChildren}
|
|
|
|
|
{orderingItem}
|
|
|
|
|
{canManageOrdering}
|
|
|
|
|
{canOpenCustomerOrdering}
|
|
|
|
|
{canCreateMixWorksheet}
|
|
|
|
|
{canCreateMixSession}
|
|
|
|
|
{canOpenSettings}
|
|
|
|
|
pagePath={page.url.pathname}
|
|
|
|
|
onOpenSettings={openSettings}
|
|
|
|
|
onSignOut={signOut}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
{#if $clientSession && whatsNewOpen && currentChangelog}
|
|
|
|
|
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.app-shell {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 252px minmax(0, 1fr);
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
background: var(--color-bg-app);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
.app-shell.sidebar-collapsed {
|
|
|
|
|
grid-template-columns: 4.5rem minmax(0, 1fr);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 11:59:04 +12:00
|
|
|
.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>
|