Access permissions, seed permissions, security, session, api/session improved handling + speed across the site/UX improvements

This commit is contained in:
2026-05-08 00:00:56 +12:00
parent ebee72d4df
commit 1533b5aa9b
29 changed files with 1851 additions and 520 deletions
+12 -20
View File
@@ -7,6 +7,9 @@
"": {
"name": "data-entry-app-frontend",
"version": "0.1.5",
"dependencies": {
"lucide-svelte": "^1.0.1"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.2.0",
"@sveltejs/adapter-node": "^5.2.12",
@@ -55,7 +58,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -66,7 +68,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -77,7 +78,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -87,14 +87,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -882,7 +880,6 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -1020,7 +1017,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/resolve": {
@@ -1034,7 +1030,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
@@ -1154,7 +1149,6 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -1167,7 +1161,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1187,7 +1180,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1207,7 +1199,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -1261,7 +1252,6 @@
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-errors": {
@@ -1285,14 +1275,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -1420,7 +1408,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -1701,14 +1688,21 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/lucide-svelte": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz",
"integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==",
"license": "ISC",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -1988,7 +1982,6 @@
"version": "5.55.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
"integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -2298,7 +2291,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
}
}
+3
View File
@@ -17,5 +17,8 @@
"typescript": "^5.5.4",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
"dependencies": {
"lucide-svelte": "^1.0.1"
}
}
+95 -10
View File
@@ -14,6 +14,7 @@ import {
import type {
ClientAccessAccount,
ClientAccessPowerBiExport,
DashboardSummary,
ClientUserCreateInput,
ClientUserModulePermission,
ClientUserUpdateInput,
@@ -125,6 +126,62 @@ async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none',
}
}
// In-memory GET cache with TTL + in-flight de-duplication. The cache key
// includes the auth-mode and last 8 chars of the bearer token so different
// sessions can't read each other's entries. Any mutation calls clearApiCache()
// to invalidate. Memory footprint is bounded by entries naturally aging out.
type CacheEntry = { value: unknown; expiresAt: number };
const responseCache = new Map<string, CacheEntry>();
const inflightRequests = new Map<string, Promise<unknown>>();
const READ_CACHE_TTL_MS = 30_000;
function makeCacheKey(path: string, auth: AuthMode) {
const token = browser ? getToken(auth) ?? '' : '';
return `${auth}:${token.slice(-8)}:${path}`;
}
async function cachedFetchJson<T>(
path: string,
fallback: T,
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<T> {
// Bypass the cache during SSR (no localStorage, no shared session).
if (!browser) {
return fetchJson<T>(path, fallback, auth, fetcher);
}
const key = makeCacheKey(path, auth);
const now = Date.now();
const cached = responseCache.get(key);
if (cached && cached.expiresAt > now) {
return cached.value as T;
}
// De-duplicate concurrent callers (e.g. two effects firing the same load).
const existing = inflightRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
const promise = fetchJson<T>(path, fallback, auth, fetcher)
.then((value) => {
responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS });
return value;
})
.finally(() => {
inflightRequests.delete(key);
});
inflightRequests.set(key, promise);
return promise;
}
export function clearApiCache() {
responseCache.clear();
inflightRequests.clear();
}
async function request<T>(
path: string,
options: RequestInit,
@@ -155,6 +212,12 @@ async function request<T>(
throw new Error(message);
}
const isMutation = !!options.method && options.method.toUpperCase() !== 'GET';
if (isMutation && browser) {
// Mutations invalidate cached reads — keeps Dashboard / lists fresh
// after the user creates or updates anything.
clearApiCache();
}
return (await response.json()) as T;
} catch (error) {
throw normalizeRequestError(error);
@@ -162,13 +225,13 @@ async function request<T>(
}
export const api = {
rawMaterials: (fetcher?: ApiFetch) => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, 'client', fetcher),
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher),
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorOptions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
mixCalculatorSessions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
@@ -186,19 +249,41 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
products: (fetcher?: ApiFetch) => fetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
productCosts: (fetcher?: ApiFetch) =>
fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
clientAccess: (fetcher?: ApiFetch) => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
clientAccessExport: (fetcher?: ApiFetch) =>
fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
dashboardSummary: (fetcher?: ApiFetch) =>
cachedFetchJson<DashboardSummary>(
'/api/dashboard/summary',
{
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] }
},
'client',
fetcher
),
clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
// Internal Hunter Stock Feeds login. Returns the same LoginResponse shape
// (with `permissions` populated) so the existing client-session store can
// consume it directly.
internalLogin: (email: string, password: string) =>
request<LoginResponse>('/api/access/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
internalSession: (fetcher?: ApiFetch) =>
request<LoginResponse>('/api/access/me', { method: 'GET' }, 'client', fetcher),
adminLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/admin/login', {
method: 'POST',
+284 -248
View File
@@ -7,6 +7,24 @@
import { featureFlags } from '$lib/features';
import { onMount, tick } from 'svelte';
import packageInfo from '../../../package.json';
import {
LayoutDashboard,
Calculator,
Wheat,
FlaskConical,
Boxes,
Workflow,
ShieldCheck,
DollarSign,
ClipboardList,
Search,
LogOut,
Plus,
Settings,
ChevronDown,
Menu
} from 'lucide-svelte';
import type { ComponentType } from 'svelte';
type SearchItem = {
href: string;
@@ -19,29 +37,31 @@
href: string;
label: string;
shortLabel: string;
icon?: 'home';
icon: ComponentType;
moduleKey?: string;
};
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' };
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: LayoutDashboard, moduleKey: 'dashboard' };
const mixCalculatorItem: NavItem = {
href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new',
label: 'Mix Calculator',
shortLabel: 'MC',
icon: Calculator,
moduleKey: 'mix_calculator'
};
const workingDocumentItems: NavItem[] = [
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' },
{ href: '/products', label: 'Products', shortLabel: 'PR', moduleKey: 'products' },
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', moduleKey: 'scenarios' }
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
{ href: '/products', label: 'Products', shortLabel: 'PR', icon: Boxes, moduleKey: 'products' },
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', icon: Workflow, moduleKey: 'scenarios' }
];
const accessControlItem: NavItem = { href: '/client-access', label: 'Client Access', shortLabel: 'AC', moduleKey: 'client_access' };
const accessControlItem: NavItem = { href: '/client-access', label: 'Client Access', shortLabel: 'AC', icon: ShieldCheck, moduleKey: 'client_access' };
const navigation = [dashboardItem, mixCalculatorItem, ...workingDocumentItems, accessControlItem];
const footerLinks = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
type FooterLink = { href: string; label: string; shortLabel: string; icon: ComponentType };
const footerLinks: FooterLink[] = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP', icon: DollarSign },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV', icon: ClipboardList }
];
const baseSearchItems: SearchItem[] = [
@@ -140,8 +160,8 @@
...footerLinks,
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
? []
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel }])
]);
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel, icon: accessControlItem.icon }])
] as FooterLink[]);
const primaryBottomNavigation = $derived(
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
@@ -162,25 +182,44 @@
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
}
function pageDescription(pathname: string) {
if (pathname.startsWith('/mix-calculator/')) {
return 'Review a saved mix calculation session and prepare a printable output';
type Crumb = { label: string; href?: string };
// Breadcrumbs replace the previous descriptive subtitle. They give every
// page the same shape — Workspace Section Subpage — so headings feel
// consistent across the app instead of each page improvising its own copy.
function breadcrumbs(pathname: string): Crumb[] {
const root: Crumb = { label: 'Workspace', href: '/' };
if (pathname === '/') {
return [root, { label: 'Dashboard' }];
}
const descriptions: Record<string, string> = {
'/': 'Hunter Premium Produce client workspace',
'/raw-materials': 'Review source input costs and downstream exposure',
'/mixes': 'Browse saved mix worksheets and costing outputs',
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce',
'/mix-calculator': 'Create and review client-specific mix calculation sessions',
'/mix-calculator/new': 'Create a new client-specific mix calculation session',
'/products': 'Track delivered product pricing and margin views',
'/settings': 'Review your workspace profile and application settings',
'/scenarios': 'Compare alternate pricing and production assumptions',
'/client-access': 'Manage user access, module permissions, and audit history'
};
if (pathname.startsWith('/mix-calculator')) {
const trail: Crumb[] = [root, { label: 'Mix Calculator', href: '/mix-calculator' }];
if (pathname === '/mix-calculator/new') trail.push({ label: 'New Session' });
else if (pathname.endsWith('/print')) trail.push({ label: 'Print' });
else if (pathname !== '/mix-calculator') trail.push({ label: 'Session' });
return trail;
}
return descriptions[pathname] ?? 'Hunter Premium Produce client workspace';
if (pathname.startsWith('/mixes')) {
const trail: Crumb[] = [root, { label: 'Mix Master', href: '/mixes' }];
if (pathname === '/mixes/new') trail.push({ label: 'New Mix' });
else if (pathname !== '/mixes') trail.push({ label: 'Detail' });
return trail;
}
const sectionMap: Record<string, string> = {
'/raw-materials': 'Raw Materials',
'/products': 'Products',
'/scenarios': 'Scenarios',
'/client-access': 'Client Access',
'/settings': 'Settings'
};
const section = sectionMap[pathname];
if (section) return [root, { label: section }];
return [root, { label: pageTitle(pathname) }];
}
function openPalette(query = '') {
@@ -255,10 +294,16 @@
restoredToken = token;
isRestoringSession = true;
api.clientSession()
// 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) => {
restoredToken = session.token;
clientSession.set(session);
// /api/access/me does not re-issue a token; preserve the existing one.
const nextToken = session.token ?? token;
restoredToken = nextToken;
clientSession.set({ ...session, token: nextToken });
return invalidateAll();
})
.catch(() => {
@@ -270,10 +315,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
// navigation. Subsequent opens hit the api.ts cache.
$effect(() => {
const hydrated = $sessionHydrated;
const session = $clientSession;
const token = session?.token ?? null;
const shouldSeed = paletteOpen;
if (!hydrated || !session || !token) {
seededSearchItems = [];
@@ -281,7 +330,7 @@
return;
}
if (seededSearchToken === token) {
if (!shouldSeed || seededSearchToken === token) {
return;
}
@@ -398,90 +447,65 @@
</div>
<div class="sidebar-body">
<p class="nav-section-label">Workspace</p>
<nav class="nav-list" aria-label="Client navigation">
{#if visibleDashboardItem}
{@const Icon = visibleDashboardItem.icon}
<a class:active={matchesRoute(visibleDashboardItem.href, page.url.pathname)} href={visibleDashboardItem.href}>
<span class="nav-icon">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M3.75 10.5 12 3.75l8.25 6.75"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.25 9.75v9h13.5v-9"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10.125 18.75v-5.25h3.75v5.25"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<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, page.url.pathname)} href={visibleMixCalculatorItem.href}>
<span class="nav-icon">
<span class="nav-icon-mask" style="--nav-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
</span>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleMixCalculatorItem.label}</span>
</a>
{/if}
</nav>
<div class="nav-group" aria-label="Working documents" hidden={!visibleWorkingDocumentItems.length}>
<button
aria-controls="working-documents-nav"
aria-expanded={workingDocumentsExpanded}
class:active={workingDocumentsActive}
class="nav-group-toggle"
type="button"
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
>
<span class="nav-group-toggle-copy">
<span class="nav-icon muted">WD</span>
<span>Working Docs</span>
</span>
<span class:open={workingDocumentsExpanded} class="chevron"></span>
</button>
{#if workingDocumentsExpanded}
<nav class="nav-sublist" id="working-documents-nav" aria-label="Working document pages">
{#each visibleWorkingDocumentItems as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
{/if}
</div>
{#if visibleWorkingDocumentItems.length}
<p class="nav-section-label">Working Docs</p>
<nav class="nav-list" aria-label="Working document pages">
{#each visibleWorkingDocumentItems as item}
{@const Icon = item.icon}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{item.label}</span>
</a>
{/each}
</nav>
{/if}
<div class="sidebar-footer">
{#each visibleFooterLinks as item}
<a href={item.href}>
<span class="nav-icon muted">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</div>
{#if visibleFooterLinks.length}
<p class="nav-section-label">More</p>
<nav class="nav-list" aria-label="Workspace shortcuts">
{#each visibleFooterLinks as item}
{@const Icon = item.icon}
<a href={item.href}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{item.label}</span>
</a>
{/each}
</nav>
{/if}
<div class="sidebar-meta">
<span class="sidebar-version-row">
<span>{appVersion}</span>
<span class="release-pill">{releaseStage}</span>
</span>
<small>&copy; {currentYear} Hunter Premium Produce</small>
{#if $clientSession}
<button class="sidebar-signout" type="button" onclick={() => clientSession.clear()}>
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
<span>Sign out</span>
</button>
{/if}
<div class="sidebar-meta-foot">
<span class="version-pill">
<span>{appVersion}</span>
<span class="release-pill">{releaseStage}</span>
</span>
<small>&copy; {currentYear} Hunter Premium Produce</small>
</div>
</div>
</div>
</aside>
@@ -491,8 +515,17 @@
<header class="topbar">
<div class="topbar-start">
<div class="topbar-copy">
<nav class="breadcrumbs" aria-label="Breadcrumb">
{#each breadcrumbs(page.url.pathname) as crumb, index}
{#if index > 0}<span class="breadcrumb-sep" aria-hidden="true">/</span>{/if}
{#if crumb.href && index < breadcrumbs(page.url.pathname).length - 1}
<a href={crumb.href}>{crumb.label}</a>
{:else}
<span aria-current={index === breadcrumbs(page.url.pathname).length - 1 ? 'page' : undefined}>{crumb.label}</span>
{/if}
{/each}
</nav>
<h1>{pageTitle(page.url.pathname)}</h1>
<p>{pageDescription(page.url.pathname)}</p>
</div>
</div>
@@ -599,42 +632,15 @@
{#if showBottomNav}
<nav class="bottom-nav" aria-label="Tablet navigation">
{#each primaryBottomNavigation as item}
{@const Icon = item.icon}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="bottom-nav-icon">
{#if item.icon === 'home'}
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M3.75 10.5 12 3.75l8.25 6.75"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.25 9.75v9h13.5v-9"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10.125 18.75v-5.25h3.75v5.25"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{:else}
{item.shortLabel}
{/if}
</span>
<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 muted">+</span>
<span class="bottom-nav-icon"><Menu size={18} strokeWidth={1.85} /></span>
<span>More</span>
</button>
</nav>
@@ -668,97 +674,58 @@
<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, page.url.pathname)} href={visibleDashboardItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M3.75 10.5 12 3.75l8.25 6.75"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.25 9.75v9h13.5v-9"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10.125 18.75v-5.25h3.75v5.25"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<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, page.url.pathname)} href={visibleMixCalculatorItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">
<span class="nav-icon-mask" style="--nav-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
</span>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleMixCalculatorItem.label}</span>
</a>
{/if}
<div class="drawer-group" hidden={!visibleWorkingDocumentItems.length}>
<button
aria-controls="drawer-working-documents-nav"
aria-expanded={workingDocumentsExpanded}
class:active={workingDocumentsActive}
class="nav-group-toggle drawer-group-toggle"
type="button"
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
>
<span class="nav-group-toggle-copy">
<span class="nav-icon muted">WD</span>
<span>Working Documents</span>
</span>
<span class:open={workingDocumentsExpanded} class="chevron"></span>
</button>
{#if workingDocumentsExpanded}
<div id="drawer-working-documents-nav" class="drawer-sublist">
{#each visibleWorkingDocumentItems as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</div>
{/if}
</div>
{#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, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
<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">
<a href="/mixes/new" onclick={() => (navOpen = false)}>
<span class="nav-icon">NW</span>
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
<span>Create mix worksheet</span>
</a>
<a href="/mix-calculator/new" onclick={() => (navOpen = false)}>
<span class="nav-icon">MC</span>
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
<span>Create mix session</span>
</a>
<button type="button" onclick={openSettings}>
<span class="nav-icon muted">ST</span>
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
<span>Change settings</span>
</button>
<a href="/products" onclick={() => (navOpen = false)}>
<span class="nav-icon muted">DP</span>
<span class="nav-icon"><DollarSign size={18} strokeWidth={1.75} /></span>
<span>Review delivered pricing</span>
</a>
<button type="button" onclick={() => openPalette('')}>
<span class="nav-icon muted">SR</span>
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
<span>Search the workspace</span>
</button>
{#if $clientSession}
<button type="button" onclick={() => clientSession.clear()}>
<span class="nav-icon muted">SO</span>
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
<span>Sign out</span>
</button>
{/if}
@@ -871,7 +838,7 @@
.app-shell {
display: grid;
grid-template-columns: 244px minmax(0, 1fr);
grid-template-columns: 252px minmax(0, 1fr);
min-height: 100vh;
}
@@ -891,14 +858,15 @@
.sidebar {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: 0.9rem;
gap: 0.4rem;
padding: 1.1rem 0.85rem 0.85rem;
background: var(--panel);
border-right: 1px solid var(--line);
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
overflow-y: auto;
scrollbar-width: thin;
}
.sidebar-body {
@@ -906,7 +874,20 @@
display: flex;
flex: 1;
flex-direction: column;
gap: 1.1rem;
gap: 0.45rem;
}
.nav-section-label {
margin: 0.85rem 0.55rem 0.3rem;
color: var(--muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.nav-section-label:first-child {
margin-top: 0.25rem;
}
.brand-row {
@@ -914,6 +895,8 @@
align-items: center;
justify-content: space-between;
gap: 0.68rem;
padding: 0 0.25rem 0.4rem;
border-bottom: 1px solid var(--line);
}
.brand {
@@ -926,11 +909,21 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
color: #6d7d74;
background: transparent;
border-radius: 0.55rem;
width: 1.6rem;
height: 1.6rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
transition: color 140ms ease, background-color 140ms ease;
}
.nav-list a:hover .nav-icon,
.nav-list a.active .nav-icon,
.sidebar-signout:hover .nav-icon {
color: var(--green-deep);
}
.nav-icon-mask {
@@ -1047,39 +1040,45 @@
font-size: 0.76rem;
}
.nav-list,
.nav-sublist,
.sidebar-footer {
.nav-list {
display: grid;
gap: 0.3rem;
gap: 0.12rem;
}
.nav-list a,
.nav-sublist a,
.sidebar-footer a {
.nav-list a {
position: relative;
display: flex;
align-items: center;
gap: 0.68rem;
padding: 0.72rem 0.68rem;
border-radius: 0.82rem;
color: #304038;
transition: background-color 160ms ease;
gap: 0.7rem;
padding: 0.6rem 0.6rem;
border-radius: 0.7rem;
color: #3a4a41;
font-size: 0.93rem;
transition: background-color 140ms ease, color 140ms ease;
}
.nav-list a:hover,
.nav-sublist a:hover,
.sidebar-footer a:hover,
.nav-list a.active,
.nav-sublist a.active {
.nav-list a:hover {
background: rgba(234, 248, 239, 0.55);
color: var(--green-deep);
}
.nav-list a.active {
background: var(--green-soft);
}
.nav-list a.active,
.nav-sublist a.active {
color: var(--green-deep);
font-weight: 600;
}
.nav-list a.active::before {
content: '';
position: absolute;
left: -0.85rem;
top: 0.45rem;
bottom: 0.45rem;
width: 3px;
border-radius: 999px;
background: var(--green-deep);
}
.nav-group {
display: grid;
gap: 0.55rem;
@@ -1138,45 +1137,62 @@
position: relative;
}
.nav-icon {
width: 1.56rem;
height: 1.56rem;
border-radius: 0.56rem;
}
.nav-icon svg,
.bottom-nav-icon svg {
width: 0.9rem;
height: 0.9rem;
}
/* `.nav-icon.muted` is kept for the bottom-nav (still uses letter labels). */
.nav-icon.muted {
color: #fff;
background: linear-gradient(135deg, #95a39b 0%, #6e7c73 100%);
}
.sidebar-footer {
margin-top: auto;
padding-top: 0.6rem;
flex-shrink: 0;
}
.sidebar-meta {
margin-top: auto;
display: grid;
gap: 0.2rem;
padding: 0.85rem 0.3rem 0;
color: var(--muted);
font-size: 0.78rem;
gap: 0.55rem;
padding-top: 0.85rem;
flex-shrink: 0;
}
.sidebar-meta small {
font-size: 0.74rem;
.sidebar-signout {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem 0.6rem;
border: none;
border-radius: 0.7rem;
background: transparent;
color: #3a4a41;
font-size: 0.93rem;
text-align: left;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease;
}
.sidebar-version-row {
.sidebar-signout:hover {
background: rgba(234, 248, 239, 0.55);
color: var(--green-deep);
}
.sidebar-meta-foot {
display: grid;
gap: 0.25rem;
padding: 0.7rem 0.55rem 0;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.76rem;
}
.sidebar-meta-foot small {
font-size: 0.72rem;
}
.version-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
gap: 0.45rem;
flex-wrap: wrap;
}
@@ -1218,20 +1234,40 @@
gap: 0.82rem;
}
.topbar-copy h1,
.topbar-copy p {
margin: 0;
}
.topbar-copy h1 {
font-size: 1.62rem;
margin: 0.18rem 0 0;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.topbar-copy p {
margin-top: 0.22rem;
.breadcrumbs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
color: var(--muted);
font-size: 0.92rem;
font-size: 0.78rem;
font-weight: 500;
}
.breadcrumbs a {
color: var(--muted);
transition: color 140ms ease;
}
.breadcrumbs a:hover {
color: var(--green-deep);
}
.breadcrumbs span[aria-current='page'] {
color: var(--text);
font-weight: 600;
}
.breadcrumb-sep {
color: #b9c5be;
font-size: 0.78rem;
}
.topbar-middle {
@@ -220,24 +220,16 @@
{/if}
</section>
{:else}
<section class="page-intro">
<div>
<p class="eyebrow">
<span class="eyebrow-icon" style="--button-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
<span>Mix Calculator</span>
</p>
<h2>{isExistingSession ? 'Edit saved mix session' : 'New mix calculation session'}</h2>
<p>Scale a saved product mix by batch size, review required raw materials, then save the session for history and printing.</p>
</div>
<div class="header-actions">
{#if featureFlags.mixCalculatorSessionHistory || initialSession}
<section class="page-actions">
{#if featureFlags.mixCalculatorSessionHistory}
<a class="secondary-button" href="/mix-calculator">Session history</a>
{/if}
{#if initialSession}
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
{/if}
</div>
</section>
</section>
{/if}
<section class="workspace-grid">
<article class="form-card">
@@ -556,11 +548,18 @@
mask: var(--button-icon-url) center / contain no-repeat;
}
.page-intro,
.page-actions,
.workspace-grid {
margin-bottom: 1.2rem;
}
.page-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
flex-wrap: wrap;
}
.page-intro {
display: flex;
align-items: flex-start;
@@ -0,0 +1,61 @@
<script lang="ts">
type Variant = 'line' | 'block' | 'circle' | 'pill';
type Props = {
variant?: Variant;
width?: string;
height?: string;
radius?: string;
class?: string;
};
let { variant = 'line', width, height, radius, class: className = '' }: Props = $props();
const defaults: Record<Variant, { width: string; height: string; radius: string }> = {
line: { width: '100%', height: '0.75rem', radius: '999px' },
block: { width: '100%', height: '6rem', radius: '0.95rem' },
circle: { width: '2.4rem', height: '2.4rem', radius: '999px' },
pill: { width: '5rem', height: '1.4rem', radius: '999px' }
};
const resolvedWidth = $derived(width ?? defaults[variant].width);
const resolvedHeight = $derived(height ?? defaults[variant].height);
const resolvedRadius = $derived(radius ?? defaults[variant].radius);
</script>
<span
aria-hidden="true"
class={`skeleton ${className}`}
style={`--sk-width:${resolvedWidth};--sk-height:${resolvedHeight};--sk-radius:${resolvedRadius};`}
></span>
<style>
.skeleton {
display: inline-block;
width: var(--sk-width);
height: var(--sk-height);
border-radius: var(--sk-radius);
background: linear-gradient(
90deg,
rgba(229, 236, 231, 0.55) 0%,
rgba(244, 247, 245, 0.92) 45%,
rgba(229, 236, 231, 0.55) 90%
)
0 0 / 220% 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
flex-shrink: 0;
vertical-align: middle;
}
@keyframes skeleton-shimmer {
0% { background-position: 220% 0; }
100% { background-position: -120% 0; }
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
background: rgba(229, 236, 231, 0.7);
}
}
</style>
+24
View File
@@ -11,6 +11,10 @@ export type AppSession = {
user_id?: number | null;
client_account_id?: number | null;
module_permissions?: Record<string, string>;
// Permission-key array, populated when the user signed in via the internal
// Hunter Stock Feeds /api/access/login endpoint. Drives feature gating.
permissions?: string[];
role_name?: string | null;
};
const ACCESS_LEVEL_ORDER: Record<string, number> = {
@@ -63,6 +67,9 @@ function createSessionStore(storageKey: string) {
clear() {
if (browser) {
localStorage.removeItem(storageKey);
// Drop any cached API responses keyed to the old session token.
// Imported lazily so this module stays free of api.ts side-effects.
import('$lib/api').then(({ clearApiCache }) => clearApiCache()).catch(() => {});
}
store.set(null);
}
@@ -102,6 +109,23 @@ export function hasModuleAccess(
return (ACCESS_LEVEL_ORDER[currentLevel] ?? 0) >= ACCESS_LEVEL_ORDER[minimumLevel];
}
// Permission-key check for the internal access-control system. Returns false
// for legacy sessions that don't carry a permissions array. UI gating only —
// every privileged backend route still enforces permissions itself.
export function hasPermission(session: AppSession | null | undefined, permissionKey: string) {
if (!session?.permissions) {
return false;
}
return session.permissions.includes(permissionKey);
}
export function hasAnyPermission(session: AppSession | null | undefined, permissionKeys: string[]) {
if (!session?.permissions) {
return false;
}
return permissionKeys.some((key) => session.permissions!.includes(key));
}
export const sessionHydrated = readable(false, (set) => {
if (!browser) {
return undefined;
+37
View File
@@ -298,6 +298,38 @@ export type ClientAccessPowerBiExport = {
clients: ClientAccessAccount[];
};
export type DashboardSummary = {
raw_materials: {
count: number;
total_market_value: number;
latest: { id: number; name: string; market_value: number; cost_per_kg: number; effective_date: string | null } | null;
} | null;
mixes: {
count: number;
average_cost_per_kg: number;
top: {
id: number;
name: string;
client_name: string;
ingredients_count: number;
total_mix_kg: number;
total_mix_cost: number;
mix_cost_per_kg: number | null;
warnings: string[];
} | null;
} | null;
products: {
count: number;
top: { id: number; product_name: string; client_name: string; finished_product_delivered: number; warnings: string[] } | null;
top_products: Array<{ id: number; product_name: string; client_name: string; finished_product_delivered: number; warnings: string[] }>;
} | null;
trend_seeds: {
raw_material_cost_per_kg: number[];
mix_cost_per_kg: number[];
product_finished_delivered: number[];
};
};
export type LoginResponse = {
name: string;
email: string;
@@ -308,6 +340,11 @@ export type LoginResponse = {
user_id?: number | null;
client_account_id?: number | null;
module_permissions?: Record<string, string>;
// Permission-key array populated by the internal Hunter Stock Feeds login.
// Drives permission-based UI gating; legacy client logins leave it undefined.
permissions?: string[];
// Display-friendly role label (e.g. "Admin", "Operations") when role === 'internal'.
role_name?: string | null;
};
export type RawMaterialCreateInput = {
+147 -96
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { api } from '$lib/api';
import { clientSession, sessionHydrated } from '$lib/session';
import type { Mix, ProductCostBreakdown, RawMaterial } from '$lib/types';
import Skeleton from '$lib/components/Skeleton.svelte';
import type { DashboardSummary } from '$lib/types';
import packageInfo from '../../package.json';
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
type Segment = {
label: string;
@@ -44,7 +46,10 @@
isLoggingIn = true;
try {
const session = await api.clientLogin(email, password);
// Authenticates against the internal Hunter Stock Feeds role/permission
// system. The response is shape-compatible with the legacy client
// session, so the rest of the app continues to work unchanged.
const session = await api.internalLogin(email, password);
clientSession.set(session);
} catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in';
@@ -99,7 +104,8 @@
}).format(new Date(value));
}
function greetingForAst() {
// Australian Eastern time-of-day → greeting + matching Lucide icon.
function timeOfDay() {
const astHour = Number(
new Intl.DateTimeFormat('en-AU', {
hour: 'numeric',
@@ -108,34 +114,36 @@
}).format(new Date())
);
return astHour < 12 ? 'Good morning' : 'Good evening';
if (astHour >= 5 && astHour < 12) return { label: 'Good morning', icon: Sunrise, tone: 'morning' as const };
if (astHour >= 12 && astHour < 17) return { label: 'Good afternoon', icon: Sun, tone: 'afternoon' as const };
if (astHour >= 17 && astHour < 21) return { label: 'Good evening', icon: Sunset, tone: 'evening' as const };
return { label: 'Good evening', icon: Moon, tone: 'night' as const };
}
function firstName(name: string | null | undefined) {
return name?.trim().split(/\s+/)[0] ?? 'there';
}
function findHighestProduct(products: ProductCostBreakdown[]) {
return [...products].sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)[0];
}
// The dashboard summary streams in after the route shell paints. Until it
// resolves, all derived state falls back to defaults so the page chrome
// stays interactive.
let summary = $state<DashboardSummary | null>(null);
function findMostExpensiveMix(mixes: Mix[]) {
return [...mixes].sort((left, right) => (right.mix_cost_per_kg ?? 0) - (left.mix_cost_per_kg ?? 0))[0];
}
$effect(() => {
let cancelled = false;
Promise.resolve(data.summary).then((value) => {
if (!cancelled) summary = value;
});
return () => {
cancelled = true;
};
});
function findLatestMaterial(materials: RawMaterial[]) {
return [...materials].sort((left, right) => {
const leftDate = left.current_price?.effective_date ?? '';
const rightDate = right.current_price?.effective_date ?? '';
return rightDate.localeCompare(leftDate);
})[0];
}
function buildSegments() {
function buildSegments(current: DashboardSummary | null) {
return [
{ label: 'Materials', value: data.rawMaterials.length, color: '#2c9b5f' },
{ label: 'Mixes', value: data.mixes.length, color: '#d7802a' },
{ label: 'Products', value: data.productCosts.length, color: '#286ea7' }
{ label: 'Materials', value: current?.raw_materials?.count ?? 0, color: '#2c9b5f' },
{ label: 'Mixes', value: current?.mixes?.count ?? 0, color: '#d7802a' },
{ label: 'Products', value: current?.products?.count ?? 0, color: '#286ea7' }
];
}
@@ -176,11 +184,12 @@
});
}
function buildTrendSeries() {
function buildTrendSeries(current: DashboardSummary | null) {
const trends = current?.trend_seeds;
const seeds = [
...data.rawMaterials.map((material: RawMaterial) => (material.current_price?.cost_per_kg ?? 0) * 780),
...data.mixes.map((mix: Mix) => (mix.mix_cost_per_kg ?? 0) * 640),
...data.productCosts.map((product: ProductCostBreakdown) => product.finished_product_delivered * 24)
...(trends?.raw_material_cost_per_kg ?? []).map((value) => value * 780),
...(trends?.mix_cost_per_kg ?? []).map((value) => value * 640),
...(trends?.product_finished_delivered ?? []).map((value) => value * 24)
].filter((value) => value > 0);
const source = seeds.length ? seeds : [320, 360, 420];
@@ -237,23 +246,23 @@
};
}
function buildFocusCards(): WorkspaceFocus[] {
const featuredMaterial = findLatestMaterial(data.rawMaterials);
const featuredMix = findMostExpensiveMix(data.mixes);
const featuredProduct = findHighestProduct(data.productCosts);
function buildFocusCards(current: DashboardSummary | null): WorkspaceFocus[] {
const featuredMaterial = current?.raw_materials?.latest ?? null;
const featuredMix = current?.mixes?.top ?? null;
const featuredProduct = current?.products?.top ?? null;
return [
{
code: 'RM',
label: featuredMaterial?.name ?? 'Raw material',
detail: `Updated ${formatDate(featuredMaterial?.current_price?.effective_date)}`,
value: currency(featuredMaterial?.current_price?.market_value),
detail: `Updated ${formatDate(featuredMaterial?.effective_date)}`,
value: currency(featuredMaterial?.market_value),
tone: 'positive'
},
{
code: 'MX',
label: featuredMix?.name ?? 'Mix worksheet',
detail: `${featuredMix?.ingredients.length ?? 0} ingredients loaded`,
detail: `${featuredMix?.ingredients_count ?? 0} ingredients loaded`,
value: `${currency(featuredMix?.mix_cost_per_kg, 4)} / kg`,
tone: featuredMix?.warnings.length ? 'warning' : 'neutral'
},
@@ -267,35 +276,24 @@
];
}
const featuredProduct = $derived(findHighestProduct(data.productCosts));
const featuredMix = $derived(findMostExpensiveMix(data.mixes));
const featuredMaterial = $derived(findLatestMaterial(data.rawMaterials));
const productionSegments = $derived(buildSegments());
const featuredProduct = $derived(summary?.products?.top ?? null);
const featuredMix = $derived(summary?.mixes?.top ?? null);
const featuredMaterial = $derived(summary?.raw_materials?.latest ?? null);
const productionSegments = $derived(buildSegments(summary));
const gaugeBars = $derived(buildGaugeBars(productionSegments));
const totalTracked = $derived(
productionSegments.reduce((sum: number, segment: Segment) => sum + segment.value, 0)
);
const totalMarketValue = $derived(
data.rawMaterials.reduce(
(sum: number, material: RawMaterial) => sum + (material.current_price?.market_value ?? 0),
0
)
);
const averageMixCost = $derived(
data.mixes.length
? data.mixes.reduce((sum: number, mix: Mix) => sum + (mix.mix_cost_per_kg ?? 0), 0) / data.mixes.length
: 0
);
const trendSeries = $derived(buildTrendSeries());
const totalMarketValue = $derived(summary?.raw_materials?.total_market_value ?? 0);
const averageMixCost = $derived(summary?.mixes?.average_cost_per_kg ?? 0);
const trendSeries = $derived(buildTrendSeries(summary));
const trendLine = $derived(linePath(trendSeries));
const trendArea = $derived(areaPath(trendSeries));
const trendFocus = $derived(focusMarker(trendSeries));
const topProducts = $derived(
[...data.productCosts]
.sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)
.slice(0, 4)
);
const focusCards = $derived(buildFocusCards());
const topProducts = $derived(summary?.products?.top_products ?? []);
const focusCards = $derived(buildFocusCards(summary));
const loading = $derived(summary === null);
const greeting = $derived(timeOfDay());
</script>
{#if !$sessionHydrated}
@@ -403,40 +401,41 @@
</section>
{:else}
<section class="dashboard-intro">
<div>
<div class="hero-label-row">
<p class="eyebrow">Client Workspace</p>
<span class="release-pill">{releaseStage}</span>
</div>
<h2>{greetingForAst()}, {firstName($clientSession?.name)}</h2>
<p>Track input pricing, mix performance, and delivered product outcomes from one client-facing workspace.</p>
<div class="greeting-row">
{#snippet greetIcon()}
{@const Icon = greeting.icon}
<span class={`greeting-icon ${greeting.tone}`} aria-hidden="true">
<Icon size={44} strokeWidth={1.6} />
</span>
{/snippet}
{@render greetIcon()}
<h2>{greeting.label}, {firstName($clientSession?.name)}</h2>
</div>
<div class="intro-actions">
<button class="secondary-button" type="button">Apr, 2026</button>
<a class="primary-button" href="/products">Review Delivered Pricing</a>
</div>
</section>
<section class="workspace-banner">
<div>
<p class="eyebrow">Account</p>
<h3>Hunter Premium Produce</h3>
<p>Lean 101 powers the client workspace while operator-only administration now lives in the separate `/admin` area.</p>
</div>
<div class="focus-grid">
{#each focusCards as card}
<article class={`focus-card ${card.tone}`}>
<span class="focus-code">{card.code}</span>
<div>
<section class="focus-row">
{#each focusCards as card, i}
<article class={`focus-card ${card.tone}`}>
<span class="focus-code">{card.code}</span>
<div>
{#if loading}
<Skeleton width="9rem" height="0.95rem" />
<Skeleton width="6rem" height="0.7rem" />
{:else}
<strong>{card.label}</strong>
<span>{card.detail}</span>
</div>
{/if}
</div>
{#if loading}
<Skeleton width="4rem" height="1rem" />
{:else}
<em>{card.value}</em>
</article>
{/each}
</div>
{/if}
</article>
{/each}
</section>
<section class="dashboard-grid">
@@ -451,13 +450,21 @@
<div class="market-layout">
<div>
<h3>{featuredMaterial?.name ?? 'No material loaded'}</h3>
<p>{formatDate(featuredMaterial?.current_price?.effective_date)}</p>
<div class="hero-value">{currency(featuredMaterial?.current_price?.market_value)}</div>
<p class="support-text">
{currency(featuredMaterial?.current_price?.cost_per_kg, 4)} / kg
<span>Current blend for Hunter Premium Produce</span>
</p>
{#if loading}
<Skeleton width="14rem" height="1.5rem" />
<div style="height:0.5rem"></div>
<Skeleton width="8rem" height="0.85rem" />
<div class="hero-value"><Skeleton width="9rem" height="2.6rem" /></div>
<Skeleton width="11rem" height="0.85rem" />
{:else}
<h3>{featuredMaterial?.name ?? 'No material loaded'}</h3>
<p>{formatDate(featuredMaterial?.effective_date)}</p>
<div class="hero-value">{currency(featuredMaterial?.market_value)}</div>
<p class="support-text">
{currency(featuredMaterial?.cost_per_kg, 4)} / kg
<span>Current blend for Hunter Premium Produce</span>
</p>
{/if}
</div>
<div class="field-emblem" aria-hidden="true">
@@ -512,7 +519,11 @@
<span>Total Input Spend</span>
<span class="metric-icon"></span>
</div>
<strong>{currency(totalMarketValue)}</strong>
{#if loading}
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
{:else}
<strong>{currency(totalMarketValue)}</strong>
{/if}
<p>Across all tracked raw materials</p>
</article>
@@ -521,7 +532,11 @@
<span>Average Mix Cost</span>
<span class="metric-icon"></span>
</div>
<strong>{currency(averageMixCost, 4)}</strong>
{#if loading}
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
{:else}
<strong>{currency(averageMixCost, 4)}</strong>
{/if}
<p>Per kg across the current mix set</p>
</article>
@@ -530,8 +545,13 @@
<span>Top Delivered Output</span>
<span class="metric-icon"></span>
</div>
<strong>{currency(featuredProduct?.finished_product_delivered)}</strong>
<p>{featuredProduct?.product_name ?? 'No products loaded'}</p>
{#if loading}
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
<p><Skeleton width="9rem" height="0.85rem" /></p>
{:else}
<strong>{currency(featuredProduct?.finished_product_delivered)}</strong>
<p>{featuredProduct?.product_name ?? 'No products loaded'}</p>
{/if}
</article>
</div>
</section>
@@ -599,7 +619,7 @@
<div class="preview-facts">
<article>
<span>Ingredients</span>
<strong>{featuredMix?.ingredients.length ?? 0}</strong>
<strong>{featuredMix?.ingredients_count ?? 0}</strong>
</article>
<article>
@@ -996,12 +1016,30 @@
.dashboard-intro,
.workspace-banner,
.focus-row,
.dashboard-grid,
.analysis-grid,
.detail-grid {
margin-bottom: 1.25rem;
}
.focus-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
}
@media (max-width: 1120px) {
.focus-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.focus-row { grid-template-columns: 1fr; }
}
.dashboard-intro h2 {
font-size: clamp(1.4rem, 2.4vw, 1.85rem);
}
.dashboard-intro,
.card-toolbar,
.metric-head,
@@ -1020,15 +1058,28 @@
align-items: flex-end;
}
.dashboard-intro h2,
.workspace-banner h3 {
.dashboard-intro h2 {
margin: 0.3rem 0 0.35rem;
font-size: clamp(1.8rem, 3vw, 2.35rem);
font-weight: 700;
}
.dashboard-intro p:last-child,
.workspace-banner p:last-child,
.greeting-row {
display: flex;
align-items: center;
gap: 1rem;
}
.greeting-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
color: var(--text);
flex-shrink: 0;
}
.card-toolbar p,
.metric-card p,
.preview-header p,
+30 -34
View File
@@ -1,42 +1,38 @@
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import type { DashboardSummary } from '$lib/types';
export async function load({ fetch }) {
const EMPTY_SUMMARY: DashboardSummary = {
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] }
};
// Streaming load: the route shell paints immediately and the dashboard fills
// in once `summary` resolves. This replaces the previous load that awaited
// five separate full collections (raw materials, mixes, all product cost
// breakdowns, scenarios, data-quality) before SvelteKit would render anything.
export function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
rawMaterials: [],
mixes: [],
productCosts: [],
scenarios: [],
dataQuality: []
};
return { summary: Promise.resolve(EMPTY_SUMMARY) };
}
// Skip data fetching for sessions that lack any dashboard-eligible module
// — the backend would just return nulls anyway.
const session = getStoredClientSession();
try {
const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'mix_master') ? api.mixes(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'scenarios') ? api.scenarios(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'dashboard') ? api.dataQuality(fetch) : Promise.resolve([])
]);
return {
rawMaterials,
mixes,
productCosts,
scenarios,
dataQuality
};
} catch {
return {
rawMaterials: [],
mixes: [],
productCosts: [],
scenarios: [],
dataQuality: []
};
const permissions = session?.module_permissions ?? {};
const hasAnyDashboardData =
session?.role === 'admin' ||
permissions.dashboard ||
permissions.raw_materials ||
permissions.mix_master ||
permissions.products;
if (!hasAnyDashboardData) {
return { summary: Promise.resolve(EMPTY_SUMMARY) };
}
return {
summary: api.dashboardSummary(fetch).catch(() => EMPTY_SUMMARY)
};
}
@@ -151,14 +151,6 @@
const previewJson = $derived(JSON.stringify(exportPreview, null, 2));
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Client Amend Area</p>
<h2>Control new users, existing users, and every feature flag in one operational workspace.</h2>
<p>The preview shows the live Power BI export payload after each amendment so the admin surface and reporting output stay aligned.</p>
</div>
</section>
<section class="metric-row">
<article class="metric-card">
<span>Total Clients</span>
@@ -18,16 +18,11 @@
}
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Mix Calculator</p>
<h2>Saved production sessions</h2>
<p>Each session preserves the scaled raw material output so it can be reopened or printed later without relying on live recipe changes.</p>
</div>
{#if canEdit}
{#if canEdit}
<section class="page-actions">
<a class="primary-button" href="/mix-calculator/new">New mix session</a>
{/if}
</section>
</section>
{/if}
<section class="metric-row">
<article class="metric-card">
@@ -123,27 +118,17 @@
text-transform: uppercase;
}
.page-intro,
.page-actions,
.metric-row,
.table-card {
margin-bottom: 1.25rem;
}
.page-intro {
.page-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
justify-content: flex-end;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 15ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.table-toolbar p,
tbody span {
@@ -283,7 +268,6 @@
}
@media (max-width: 760px) {
.page-intro,
.table-toolbar {
flex-direction: column;
align-items: flex-start;
+8 -12
View File
@@ -104,16 +104,8 @@
});
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Mix Master</p>
<h2>Saved mixes in a clean table view.</h2>
<p>Use the table to browse mixes, then open a dedicated worksheet page to edit or create a formulation.</p>
</div>
<div class="intro-actions">
<a class="primary-button" href="/mixes/new">New Mix Worksheet</a>
</div>
<section class="page-actions">
<a class="primary-button" href="/mixes/new">New Mix Worksheet</a>
</section>
<section class="metric-row">
@@ -221,13 +213,17 @@
text-transform: uppercase;
}
.page-intro,
.page-actions,
.metric-row,
.table-card {
margin-bottom: 1.12rem;
}
.page-intro,
.page-actions {
display: flex;
justify-content: flex-end;
}
.metric-card,
.table-card {
background: var(--panel);
@@ -46,14 +46,6 @@
);
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Output Pricing</p>
<h2>Delivered product pricing</h2>
<p>Each row carries the product, mix source, price outputs, and a quick health state in one compact layout.</p>
</div>
</section>
<section class="metric-row">
<article class="metric-card">
<span>Total Products</span>
@@ -152,19 +152,6 @@
<a href="/">Return to sign-in</a>
</section>
{:else}
<section class="page-intro">
<div>
<p class="eyebrow">Input Cost Control</p>
<h2>Maintain raw materials with a cleaner operational workflow.</h2>
<p>Update source pricing, track downstream exposure, and keep the costing engine current from one workspace.</p>
</div>
<div class="intro-chip">
<span>{$clientSession.email}</span>
<strong>{activeMaterials.length} active materials</strong>
</div>
</section>
{#if successMessage}
<p class="feedback success">{successMessage}</p>
{/if}
@@ -11,14 +11,6 @@
const approvedCount = $derived(scenarioRows.filter((scenario) => scenario.status === 'approved').length);
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Scenario Sandbox</p>
<h2>Simulation workspaces with a cleaner review and comparison layer.</h2>
<p>Scenarios now read like structured operating plans instead of raw debug output.</p>
</div>
</section>
<section class="metric-row">
<article class="metric-card">
<span>Total Scenarios</span>
@@ -4,14 +4,6 @@
const currentYear = new Date().getFullYear();
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Workspace Settings</p>
<h2>Account and workspace preferences.</h2>
<p>Review your current session, navigation setup, and the client workspace details shown across the app.</p>
</div>
</section>
<section class="settings-grid">
<article class="surface-card">
<p class="eyebrow">Session</p>