This commit is contained in:
2026-06-09 21:28:53 +12:00
parent daa6e60a69
commit 349e4a4b5b
61 changed files with 6404 additions and 1382 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hunter-app",
"version": "0.1.11b",
"version": "0.1.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hunter-app",
"version": "0.1.11b",
"version": "0.1.12",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hunter-app",
"version": "0.1.11b",
"version": "0.1.12",
"private": true,
"type": "module",
"scripts": {
+15
View File
@@ -4,6 +4,21 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.png" />
<script>
// Resolve the theme before first paint so there is no light-mode flash.
(function () {
try {
var pref = localStorage.getItem('theme');
var dark =
pref === 'dark' ||
((!pref || pref === 'system') &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
} catch (e) {
document.documentElement.dataset.theme = 'light';
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+40 -47
View File
@@ -1,16 +1,5 @@
import { env } from '$env/dynamic/public';
import { browser } from '$app/environment';
import {
mockClientAccess,
mockClientAccessExport,
mockCosts,
mockMixCalculatorOptions,
mockMixCalculatorSessions,
mockMixes,
mockProducts,
mockRawMaterials,
mockScenarios
} from '$lib/mock';
import type {
ClientAccessAccount,
ClientAccessPowerBiExport,
@@ -34,6 +23,9 @@ import type {
MixUpdateInput,
Product,
ProductCostBreakdown,
ProductCostingInputs,
ProductCostingItem,
ProductCostingItemUpdateInput,
RawMaterial,
RawMaterialCreateInput,
RawMaterialPriceCreateInput,
@@ -136,23 +128,17 @@ function normalizeRequestError(error: unknown) {
return new Error('An unexpected error occurred while contacting the server.');
}
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
async function fetchJson<T>(path: string, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
try {
const response = await fetcher(resolveRequestUrl(path, fetcher), {
credentials: 'include'
});
if (!response.ok) {
if (auth !== 'none') {
throw new Error(response.statusText || 'Unauthorized');
}
return fallback;
throw new Error(response.statusText || 'Request failed');
}
return (await response.json()) as T;
} catch (error) {
if (auth !== 'none') {
throw normalizeRequestError(error);
}
return fallback;
throw normalizeRequestError(error);
}
}
@@ -172,13 +158,12 @@ function makeCacheKey(path: string, auth: AuthMode) {
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);
return fetchJson<T>(path, auth, fetcher);
}
const key = makeCacheKey(path, auth);
@@ -194,7 +179,7 @@ async function cachedFetchJson<T>(
return existing as Promise<T>;
}
const promise = fetchJson<T>(path, fallback, auth, fetcher)
const promise = fetchJson<T>(path, auth, fetcher)
.then((value) => {
responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS });
return value;
@@ -290,13 +275,13 @@ async function requestBlob(
}
export const api = {
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher),
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson<Mix[]>('/api/mixes', 'client', fetcher),
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorOptions: (fetcher?: ApiFetch) =>
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', 'client', fetcher),
mixCalculatorSessions: (fetcher?: ApiFetch) =>
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', 'client', fetcher),
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) =>
@@ -321,7 +306,7 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', 'client', fetcher),
editorProducts: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.q) search.set('q', params.q);
@@ -329,7 +314,7 @@ export const api = {
if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString();
const path = qs ? `/api/editor/products?${qs}` : '/api/editor/products';
return cachedFetchJson<EditorProductRow[]>(path, [], 'client', fetcher);
return cachedFetchJson<EditorProductRow[]>(path, 'client', fetcher);
},
updateEditorProduct: (productId: number, payload: EditorProductUpdateInput) =>
request<EditorProductRow>(`/api/editor/products/${productId}`, {
@@ -358,10 +343,28 @@ export const api = {
method: 'DELETE'
}, 'client'),
productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
productCostingItems: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostingItem[]>('/api/product-costing/items', 'client', fetcher),
productCostingItemsFresh: () =>
request<ProductCostingItem[]>(`/api/product-costing/items?_=${Date.now()}`, { method: 'GET' }, 'client'),
productCostingInputs: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostingInputs>('/api/product-costing/inputs', 'client', fetcher),
updateProductCostingInputs: (payload: Partial<ProductCostingInputs>) =>
request<ProductCostingInputs>('/api/product-costing/inputs', {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
updateProductCostingItem: (itemId: number, payload: ProductCostingItemUpdateInput) =>
request<ProductCostingItem>(`/api/product-costing/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
recalculateProductCosting: () =>
request<{ recalculated: number }>('/api/product-costing/recalculate-all', { method: 'POST' }, 'client'),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', 'client', fetcher),
throughputProducts: (fetcher?: ApiFetch) =>
cachedFetchJson<ThroughputProduct[]>('/api/throughput/products', [], 'client', fetcher),
cachedFetchJson<ThroughputProduct[]>('/api/throughput/products', 'client', fetcher),
throughputEntries: (params?: ThroughputEntryListParams, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.date_from) search.set('date_from', params.date_from);
@@ -372,7 +375,7 @@ export const api = {
if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString();
const path = qs ? `/api/throughput/entries?${qs}` : '/api/throughput/entries';
return cachedFetchJson<ThroughputEntry[]>(path, [], 'client', fetcher);
return cachedFetchJson<ThroughputEntry[]>(path, 'client', fetcher);
},
createThroughputEntry: (payload: ThroughputEntryCreateInput) =>
request<ThroughputEntry>('/api/throughput/entries', {
@@ -389,22 +392,12 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', 'manager', fetcher),
clientAccessExport: (fetcher?: ApiFetch) =>
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', '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
),
cachedFetchJson<DashboardSummary>('/api/dashboard/summary', 'client', fetcher),
clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST',
@@ -163,10 +163,8 @@
min-height: 100vh;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
background:
radial-gradient(circle at top left, rgba(167, 217, 190, 0.22), transparent 34%),
linear-gradient(180deg, #f7f8f4 0%, #eef2ea 100%);
color: #203028;
background: var(--color-bg-app);
color: var(--color-text-primary);
}
.admin-sidebar {
+66 -470
View File
@@ -17,6 +17,7 @@
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster,
canOpenProductCosting as sessionCanOpenProductCosting,
canOpenReporting as sessionCanOpenReporting,
canOpenSettings as sessionCanOpenSettings,
canOpenThroughput as sessionCanOpenThroughput,
@@ -28,6 +29,7 @@
import {
accessControlItem,
baseSearchItems,
buildClientNavEntries,
clientBreadcrumbs,
dashboardItem,
editorItem,
@@ -35,6 +37,7 @@
matchesRoute,
mixCalculatorItem,
pageTitle,
productCostingItem,
reportingItem,
throughputItem,
type FooterLink,
@@ -96,10 +99,27 @@
})
);
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : 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] : []),
...visibleWorkingDocumentItems
],
throughput: visibleThroughputItem,
reporting: visibleReportingItem
})
);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([
@@ -112,6 +132,7 @@
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...visibleWorkingDocumentItems.slice(0, 2)
]
);
@@ -124,6 +145,7 @@
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 === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings;
@@ -377,15 +399,8 @@
{#if !showBottomNav}
<ClientPrimaryRail
currentPath={shellPathname}
primaryItems={[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleThroughputItem ? [visibleThroughputItem] : []),
...(visibleReportingItem ? [visibleReportingItem] : [])
]}
entries={navEntries}
brandHref={workspaceHomeHref}
workingDocumentItems={visibleWorkingDocumentItems}
footerItems={visibleFooterLinks}
{appVersion}
{currentYear}
@@ -528,6 +543,15 @@
</a>
{/if}
{#if visibleProductCostingItem}
{@const Icon = visibleProductCostingItem.icon}
<a class:active={matchesRoute(visibleProductCostingItem.href, page.url.pathname)} href={visibleProductCostingItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleProductCostingItem.label}</span>
{#if visibleProductCostingItem.badge}<span class="drawer-badge">{visibleProductCostingItem.badge}</span>{/if}
</a>
{/if}
{#if visibleThroughputItem}
{@const Icon = visibleThroughputItem.icon}
<a class:active={matchesRoute(visibleThroughputItem.href, page.url.pathname)} href={visibleThroughputItem.href} onclick={() => (navOpen = false)}>
@@ -659,433 +683,9 @@
{/if}
<style>
:global(:root) {
/* ── Brand ──────────────────────────────────────────────── */
--color-brand: oklch(0.54 0.15 149);
--color-brand-hover: oklch(0.47 0.14 149);
--color-brand-tint: oklch(0.98 0.02 149);
/* ── Surfaces ───────────────────────────────────────────── */
--color-bg-app: oklch(0.975 0.006 150);
--color-bg-surface: oklch(0.997 0.004 150);
--color-bg-elevated: oklch(0.99 0.005 150);
/* ── Borders ────────────────────────────────────────────── */
--color-border: oklch(0.905 0.012 150);
--color-divider: oklch(0.935 0.009 150);
/* ── Text ───────────────────────────────────────────────── */
--color-text-primary: oklch(0.26 0.015 150);
--color-text-secondary: oklch(0.44 0.018 150);
--color-text-muted: oklch(0.62 0.018 150);
/* ── Semantic ───────────────────────────────────────────── */
--color-success: #1a7f37;
--color-warning: #bf8700;
--color-error: #cf222e;
--color-info: #0969da;
--color-warning-tint: oklch(0.975 0.035 78);
--color-info-tint: oklch(0.97 0.025 230);
/* ── Legacy aliases (keep old token names working) ───────── */
--bg: var(--color-bg-app);
--panel: var(--color-bg-surface);
--panel-soft: var(--color-bg-app);
--line: var(--color-border);
--line-strong: var(--color-border);
--text: var(--color-text-primary);
--muted: var(--color-text-muted);
--green: var(--color-brand);
--green-deep: oklch(0.25 0.018 150);
--green-soft: var(--color-brand-tint);
--blue-soft: var(--color-info-tint);
--shadow: none; /* flat design — use borders, not shadows */
--radius-panel: 1.2rem;
--radius-control: 0.82rem;
--radius-row: 0.95rem;
--space-page: 1.25rem;
--space-card: 1.15rem;
}
:global(html, body) {
margin: 0;
min-height: 100%;
background: var(--color-bg-app);
color: var(--color-text-primary);
font-family: "Inter", "Segoe UI", sans-serif;
font-size: 14px;
}
:global(*) {
box-sizing: border-box;
}
:global(h1, h2, h3, h4, h5, h6) {
font-family: "Inter", "Segoe UI", sans-serif;
letter-spacing: -0.03em;
}
:global(button),
:global(input),
:global(select),
:global(textarea) {
font: inherit;
}
:global(a) {
color: inherit;
text-decoration: none;
}
:global(:focus-visible) {
outline: 3px solid color-mix(in srgb, var(--color-brand) 38%, transparent);
outline-offset: 2px;
}
:global(.ui-stack) {
display: grid;
gap: var(--space-page);
}
:global(.ui-panel),
:global(.ui-metric-card) {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
box-shadow: var(--shadow);
}
:global(.ui-panel) {
padding: var(--space-card);
}
:global(.ui-panel-soft) {
background: var(--panel-soft);
border: 1px solid var(--line);
border-radius: var(--radius-row);
}
:global(.ui-section-heading) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
margin-bottom: 1rem;
}
:global(.ui-section-heading h3),
:global(.ui-section-heading h4) {
margin: 0.18rem 0 0;
font-size: 1.06rem;
font-weight: 700;
letter-spacing: 0;
}
:global(.ui-eyebrow) {
margin: 0;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
:global(.ui-muted) {
color: var(--muted);
}
:global(.ui-metric-row) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
:global(.ui-metric-card) {
padding: 1.05rem 1.1rem;
}
:global(.ui-metric-card span) {
display: block;
color: var(--muted);
font-size: 0.84rem;
}
:global(.ui-metric-card strong) {
display: block;
margin: 0.5rem 0 0.28rem;
font-size: 1.75rem;
font-weight: 700;
}
:global(.ui-metric-card p) {
margin: 0;
color: var(--muted);
}
:global(.ui-button) {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.6rem;
padding: 0.72rem 0.9rem;
border-radius: var(--radius-control);
font-weight: 600;
cursor: pointer;
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1), border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), color 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
:global(.ui-button.primary) {
border: 1px solid var(--color-brand);
color: oklch(0.99 0.004 150);
background: var(--color-brand);
}
:global(.ui-button.primary:hover:not(:disabled)) {
background: var(--color-brand-hover);
border-color: var(--color-brand-hover);
}
:global(.ui-button.secondary) {
border: 1px solid var(--line-strong);
color: var(--text);
background: var(--panel);
}
:global(.ui-button.secondary:hover:not(:disabled)) {
background: var(--panel-soft);
}
:global(.ui-button:disabled) {
opacity: 0.55;
cursor: not-allowed;
}
:global(.ui-pill) {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
border-radius: 999px;
padding: 0.4rem 0.74rem;
font-size: 0.82rem;
font-weight: 600;
text-transform: capitalize;
white-space: nowrap;
}
:global(.ui-pill.positive) {
color: var(--green-deep);
background: var(--green-soft);
}
:global(.ui-pill.warning) {
color: oklch(0.45 0.11 69);
background: var(--color-warning-tint);
}
:global(.ui-pill.neutral) {
color: var(--color-text-secondary);
background: color-mix(in srgb, var(--panel-soft) 74%, var(--panel));
}
:global(.ui-table-wrap) {
overflow-x: auto;
}
:global(.ui-table) {
width: 100%;
min-width: 48rem;
border-collapse: separate;
border-spacing: 0 0.65rem;
}
:global(.ui-table th),
:global(.ui-table td) {
padding: 0.9rem 0.95rem;
text-align: left;
white-space: nowrap;
}
:global(.ui-table th) {
color: var(--muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
:global(.ui-table tbody td) {
background: var(--panel-soft);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
:global(.ui-table tbody td:first-child) {
border-left: 1px solid var(--line);
border-radius: var(--radius-row) 0 0 var(--radius-row);
}
:global(.ui-table tbody td:last-child) {
border-right: 1px solid var(--line);
border-radius: 0 var(--radius-row) var(--radius-row) 0;
}
:global(.ui-table-identity) {
display: flex;
align-items: center;
gap: 0.74rem;
min-width: 0;
}
:global(.ui-row-mark) {
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.76rem;
color: oklch(0.99 0.004 150);
background: var(--green-deep);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
}
:global(.ui-table-identity strong),
:global(.ui-number-block strong) {
display: block;
font-size: 0.94rem;
}
:global(.ui-table-identity span),
:global(.ui-number-block span) {
display: block;
margin-top: 0.16rem;
color: var(--muted);
font-size: 0.8rem;
}
:global(.ui-number-block) {
display: grid;
gap: 0.08rem;
}
:global(.ui-form-grid) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
:global(.ui-form-grid.compact) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
:global(.ui-field) {
display: grid;
gap: 0.36rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
font-weight: 600;
}
:global(.ui-field input),
:global(.ui-field textarea),
:global(.ui-field select) {
width: 100%;
padding: 0.82rem 0.9rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-control);
background: var(--panel-soft);
color: var(--text);
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1), border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
:global(.ui-field input:focus),
:global(.ui-field textarea:focus),
:global(.ui-field select:focus) {
outline: none;
border-color: var(--color-brand);
background: var(--panel);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
@media (max-width: 980px) {
:global(.ui-metric-row) {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
:global(.ui-section-heading) {
flex-direction: column;
align-items: flex-start;
}
:global(.ui-table) {
min-width: 0;
border-spacing: 0;
}
:global(.ui-table),
:global(.ui-table thead),
:global(.ui-table tbody),
:global(.ui-table tr),
:global(.ui-table td) {
display: block;
width: 100%;
}
:global(.ui-table thead) {
display: none;
}
:global(.ui-table tbody) {
display: grid;
gap: 0.85rem;
}
:global(.ui-table tbody tr) {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: var(--radius-row);
background: var(--panel-soft);
}
:global(.ui-table tbody td) {
padding: 0.76rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
:global(.ui-table tbody td:first-child),
:global(.ui-table tbody td:last-child) {
border: none;
border-radius: 0;
}
:global(.ui-table tbody td + td) {
border-top: 1px solid var(--line);
}
:global(.ui-table tbody td::before) {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
:global(.ui-form-grid),
:global(.ui-form-grid.compact) {
grid-template-columns: 1fr;
}
}
/* Design tokens and the shared .ui-* surface classes now live in
$lib/styles/theme.css (imported once in the root layout) so every shell,
client / admin / error, themes consistently in light and dark. */
.app-shell {
display: grid;
@@ -1126,7 +726,7 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #6d7d74;
color: var(--color-text-muted);
background: transparent;
border-radius: 0.55rem;
width: 1.6rem;
@@ -1210,13 +810,13 @@
}
.search-placeholder {
color: #93a098;
color: var(--color-text-muted);
}
.search-icon {
width: 0.82rem;
height: 0.82rem;
border: 2px solid #98a59d;
border: 2px solid var(--color-text-muted);
border-radius: 999px;
}
@@ -1228,7 +828,7 @@
width: 0.42rem;
height: 2px;
border-radius: 999px;
background: #98a59d;
background: var(--color-text-muted);
transform: rotate(45deg);
}
@@ -1237,7 +837,7 @@
border: 1px solid var(--line-strong);
border-radius: 0.42rem;
color: var(--muted);
background: #fff;
background: var(--color-bg-surface);
font-size: 0.76rem;
}
@@ -1257,7 +857,7 @@
border: none;
border-radius: 0.82rem;
background: transparent;
color: #304038;
color: var(--color-text-primary);
cursor: pointer;
transition: background-color 160ms ease;
}
@@ -1301,8 +901,8 @@
/* `.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%);
color: var(--color-on-brand);
background: var(--color-brand);
}
.main-shell {
@@ -1334,9 +934,8 @@
padding: 0.4rem;
border: 1px solid var(--color-border);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
backdrop-filter: blur(10px);
background: var(--color-bg-elevated);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.quick-fab-wrap {
@@ -1357,7 +956,7 @@
border: none;
border-radius: 999px;
background: var(--color-brand);
color: #fff;
color: var(--color-on-brand);
box-shadow: none;
font-weight: 700;
letter-spacing: 0.01em;
@@ -1408,7 +1007,7 @@
.menu-panel button {
padding: 0.72rem 0.78rem;
border-radius: 0.78rem;
color: #304038;
color: var(--color-text-primary);
text-align: left;
background: transparent;
border: none;
@@ -1585,11 +1184,10 @@
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
padding: 0.6rem;
border: 1px solid rgba(217, 228, 221, 0.92);
border: 1px solid var(--color-border);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
backdrop-filter: blur(16px);
background: var(--color-bg-surface);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.quick-fab-wrap {
@@ -1606,7 +1204,7 @@
border: none;
border-radius: 1rem;
background: transparent;
color: #51635a;
color: var(--color-text-secondary);
text-align: center;
font-size: 0.74rem;
font-weight: 700;
@@ -1626,14 +1224,14 @@
align-items: center;
justify-content: center;
border-radius: 0.78rem;
color: #fff;
background: var(--green-deep);
color: var(--color-on-brand);
background: var(--color-brand);
font-size: 0.66rem;
letter-spacing: 0.04em;
}
.bottom-nav-icon.muted {
background: #8b949e;
background: var(--color-text-muted);
}
.bottom-drawer {
@@ -1647,10 +1245,8 @@
padding: 0.85rem 1rem calc(6.6rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
border-radius: 1.6rem 1.6rem 0 0;
background:
linear-gradient(180deg, rgba(248, 251, 249, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);
box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
backdrop-filter: blur(16px);
background: var(--color-bg-surface);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
.drawer-handle {
@@ -1658,7 +1254,7 @@
height: 0.34rem;
margin: 0 auto;
border-radius: 999px;
background: #c8d4ce;
background: var(--color-border);
}
.drawer-header {
@@ -1673,7 +1269,7 @@
}
:global(.drawer-search) {
background: #fff;
background: var(--color-bg-surface);
}
.drawer-grid {
@@ -1695,23 +1291,23 @@
padding: 0.82rem 0.86rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.88);
color: #304038;
background: var(--color-bg-surface);
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
}
.drawer-section a.active {
color: var(--green-deep);
background: var(--green-soft);
color: var(--color-brand-hover);
background: color-mix(in srgb, var(--color-brand) 11%, var(--color-bg-surface));
}
.drawer-badge {
margin-left: auto;
padding: 0.08rem 0.4rem;
border-radius: 999px;
background: #fdf0d2;
color: #8a5a00;
background: var(--color-warning-tint);
color: var(--color-warning-text);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.06em;
@@ -1747,8 +1343,8 @@
padding: 0.82rem 0.9rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.88);
color: #304038;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-weight: 600;
}
@@ -62,8 +62,8 @@
<div><span>Total bags</span><strong>{formatNumber(session.total_bags, 2)}</strong><small>{session.product_unit_of_measure}</small></div>
<div><span>Prepared by</span><strong>{session.prepared_by_name}</strong><small>{formatDate(session.mix_date)}</small></div>
<div><span>Client</span><strong>{session.client_name}</strong></div>
<div><span>Product</span><strong>{session.product_name}</strong></div>
<div><span>Mix source</span><strong>{session.mix_name}</strong></div>
<div><span>Mix</span><strong>{session.product_name}</strong></div>
<div><span>Formula source</span><strong>{session.mix_name}</strong></div>
<div><span>Unit size</span><strong>{formatNumber(session.product_unit_size_kg, 2)}kg</strong></div>
<div><span>Batch size</span><strong>{formatNumber(session.batch_size_kg, 2)}kg</strong></div>
</section>
@@ -1,23 +1,62 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { api } from '$lib/api';
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
import type { MixCalculatorSession } from '$lib/types';
let { session }: { session: MixCalculatorSession } = $props();
let { session, autoPrint = true }: { session: MixCalculatorSession; autoPrint?: boolean } = $props();
let pdfUrl = $state<string | null>(null);
let loading = $state(true);
let error = $state('');
let printAfterLoad = $state(false);
let pdfFrame = $state<HTMLIFrameElement | null>(null);
const printableTitle = $derived(
`MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_')
);
async function openPdf() {
const blob = await api.mixCalculatorSessionPdf(session.id);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener,noreferrer');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
function revokePdfUrl() {
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
pdfUrl = null;
}
}
async function loadPdf() {
loading = true;
error = '';
revokePdfUrl();
try {
const blob = await api.mixCalculatorSessionPdf(session.id);
pdfUrl = URL.createObjectURL(blob);
printAfterLoad = autoPrint;
} catch (loadError) {
error = loadError instanceof Error ? loadError.message : 'Unable to load the PDF preview.';
} finally {
loading = false;
}
}
function printPage() {
if (!pdfUrl) {
printAfterLoad = true;
loadPdf();
return;
}
pdfFrame?.contentWindow?.focus();
pdfFrame?.contentWindow?.print();
}
function handlePreviewLoaded() {
if (printAfterLoad) {
printAfterLoad = false;
requestAnimationFrame(() => printPage());
}
}
async function downloadPdf() {
const blob = await api.mixCalculatorSessionPdf(session.id);
const blob = pdfUrl ? await fetch(pdfUrl).then((response) => response.blob()) : await api.mixCalculatorSessionPdf(session.id);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
@@ -27,6 +66,14 @@
anchor.remove();
URL.revokeObjectURL(url);
}
onMount(() => {
loadPdf();
});
onDestroy(() => {
revokePdfUrl();
});
</script>
<svelte:head>
@@ -36,11 +83,28 @@
<section class="print-page">
<div class="print-toolbar">
<a class="secondary-button" href={`/mix-calculator/${session.id}`}>Back to session</a>
<button class="primary-button" type="button" onclick={openPdf}>Open Styled PDF</button>
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
<button class="primary-button" type="button" disabled={!pdfUrl && loading} onclick={printPage}>Print</button>
<button class="secondary-button" type="button" disabled={!pdfUrl && loading} onclick={downloadPdf}>Download PDF</button>
</div>
<MixCalculatorPrintDocument session={session} generatedAt={new Date().toISOString()} />
<div class="pdf-preview-shell">
{#if loading}
<div class="preview-state">Loading PDF preview...</div>
{:else if error}
<div class="preview-state error">
<strong>PDF preview unavailable</strong>
<span>{error}</span>
<button class="secondary-button" type="button" onclick={loadPdf}>Retry</button>
</div>
{:else if pdfUrl}
<iframe
bind:this={pdfFrame}
src={pdfUrl}
title={`${printableTitle} PDF preview`}
onload={handlePreviewLoaded}
></iframe>
{/if}
</div>
</section>
<style>
@@ -57,6 +121,7 @@
display: flex;
justify-content: flex-end;
gap: 0.75rem;
width: min(100%, 210mm);
}
.primary-button,
@@ -82,6 +147,52 @@
color: #304038;
}
button:disabled {
cursor: wait;
opacity: 0.65;
}
.pdf-preview-shell {
width: min(100%, 210mm);
aspect-ratio: 210 / 297;
border: 1px solid var(--line-strong);
border-radius: 0.75rem;
background: #fff;
overflow: hidden;
box-shadow: 0 18px 50px rgba(25, 35, 30, 0.16);
}
iframe {
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
.preview-state {
display: grid;
place-items: center;
gap: 0.75rem;
height: 100%;
padding: 2rem;
color: var(--color-text-secondary);
text-align: center;
font-weight: 600;
}
.preview-state.error {
align-content: center;
}
.preview-state strong,
.preview-state span {
display: block;
}
.preview-state strong {
color: var(--color-text-primary);
}
@media (max-width: 900px) {
.print-toolbar {
justify-content: stretch;
@@ -93,16 +204,9 @@
}
@media print {
:global(body) {
background: #fff;
}
.print-page {
padding: 0;
background: #fff;
}
.print-toolbar {
.print-page,
.print-toolbar,
.pdf-preview-shell {
display: none;
}
}
@@ -0,0 +1,42 @@
<script lang="ts">
import { Moon, Sun } from 'lucide-svelte';
import { resolvedTheme, toggleTheme } from '$lib/theme';
const isDark = $derived($resolvedTheme === 'dark');
</script>
<button
class="theme-toggle"
type="button"
onclick={toggleTheme}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{#if isDark}
<Sun size={18} strokeWidth={1.75} />
{:else}
<Moon size={18} strokeWidth={1.75} />
{/if}
</button>
<style>
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
flex-shrink: 0;
border: 1px solid var(--color-border);
border-radius: 0.82rem;
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.theme-toggle:hover {
background: var(--color-surface-hover);
color: var(--color-text-primary);
}
</style>
@@ -1,7 +1,8 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { api } from '$lib/api';
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
import { featureFlags } from '$lib/features';
import { formatNumber } from '$lib/format';
import { clientSession, hasModuleAccess } from '$lib/session';
import { toast } from '$lib/toast';
import type {
@@ -52,8 +53,11 @@
let notes = $state(initialNotesValue());
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
let formError = $state('');
let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock products.');
let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock mixes.');
let previewLoading = $state(false);
let printPdfUrl = $state<string | null>(null);
let printFrame = $state<HTMLIFrameElement | null>(null);
let printAfterPdfLoad = $state(false);
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
const availableClients = $derived(
@@ -91,14 +95,6 @@
}
});
function formatNumber(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return value.toFixed(digits);
}
function buildPayload(): MixCalculatorCreateInput | null {
formError = '';
formHint = '';
@@ -115,13 +111,13 @@
return null;
}
if (!clientName) {
formError = 'Select a client to unlock matching products.';
formHint = 'Products stay disabled until a client is selected.';
formError = 'Select a client to unlock matching mixes.';
formHint = 'Mixes stay disabled until a client is selected.';
return null;
}
if (!productId) {
formError = 'Select a product.';
formHint = 'Pick one of the products available for the selected client.';
formError = 'Select a mix.';
formHint = 'Pick one of the mixes available for the selected client.';
return null;
}
if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) {
@@ -171,26 +167,26 @@
notes = '';
preview = null;
formError = '';
formHint = 'Select a mix date and prepared by name, then choose a client to unlock products.';
formHint = 'Select a mix date and prepared by name, then choose a client to unlock mixes.';
}
$effect(() => {
if (!clientName) {
formHint = 'Select a client to unlock the product list.';
formHint = 'Select a client to unlock the mix list.';
return;
}
if (!filteredProducts.length) {
formHint = `No products are available for ${clientName}.`;
formHint = `No mixes are available for ${clientName}.`;
return;
}
if (!productId) {
formHint = 'Select a product for the chosen client.';
formHint = 'Select a mix for the chosen client.';
return;
}
formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected product'}.`;
formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected mix'}.`;
});
async function downloadPdf() {
@@ -219,27 +215,53 @@
}
}
async function openPdf() {
const tid = toast.loading('Opening styled PDF…');
try {
const payload = buildPayload();
if (!payload) {
toast.dismiss(tid);
toast.error(formError || 'Complete the mix details first.');
return;
}
const blob = await api.previewMixCalculatorPdf(payload);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener,noreferrer');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
toast.dismiss(tid);
} catch (error) {
toast.dismiss(tid);
toast.error(error instanceof Error ? error.message : 'Unable to open the styled PDF.');
function revokePrintPdfUrl() {
if (printPdfUrl) {
URL.revokeObjectURL(printPdfUrl);
printPdfUrl = null;
}
}
async function printCurrent() {
if (!preview) {
toast.error('Calculate the mix before printing.');
return;
}
const payload = buildPayload();
if (!payload) {
toast.error(formError || 'Complete the mix details first.');
return;
}
const tid = toast.loading('Preparing print...');
try {
const blob = initialSession ? await api.mixCalculatorSessionPdf(initialSession.id) : await api.previewMixCalculatorPdf(payload);
revokePrintPdfUrl();
printAfterPdfLoad = true;
printPdfUrl = URL.createObjectURL(blob);
toast.dismiss(tid);
} catch (error) {
toast.dismiss(tid);
toast.error(error instanceof Error ? error.message : 'Unable to prepare the PDF for printing.');
}
}
function printLoadedPdf() {
printFrame?.contentWindow?.focus();
printFrame?.contentWindow?.print();
}
function handlePrintFrameLoad() {
if (printAfterPdfLoad) {
printAfterPdfLoad = false;
requestAnimationFrame(printLoadedPdf);
}
}
onDestroy(() => {
revokePrintPdfUrl();
});
</script>
{#if !canEdit && !initialSession}
@@ -258,8 +280,7 @@
<a class="secondary-button" href="/mix-calculator">Session history</a>
{/if}
{#if initialSession}
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Open PDF page</a>
<button class="primary-button" type="button" onclick={openPdf}>Open PDF in new tab</button>
<a class="primary-button" href={`/mix-calculator/${initialSession.id}/print`}>Print</a>
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
{/if}
</section>
@@ -270,7 +291,7 @@
<div class="section-header">
<div>
<h3>Session Inputs</h3>
<p>Batch size drives the scale factor. Total bags are derived from the selected product unit size.</p>
<p>Batch size drives the scale factor. Total bags are derived from the selected mix unit size.</p>
</div>
{#if selectedProduct}
<div class="product-pill">
@@ -301,7 +322,7 @@
<label>
<span>Client</span>
<select bind:value={clientName} disabled={!canEdit} title="Select a client to unlock matching products.">
<select bind:value={clientName} disabled={!canEdit} title="Select a client to unlock matching mixes.">
<option value="">Select a client</option>
{#each availableClients as client}
<option value={client}>{client}</option>
@@ -310,16 +331,16 @@
</label>
<label>
<span>Product</span>
<span>Mix Name</span>
<select
bind:value={productId}
disabled={!canEdit || !clientName || !filteredProducts.length}
title={!clientName ? 'Select a client first.' : !filteredProducts.length ? 'No products are available for the selected client.' : 'Select a product.'}
title={!clientName ? 'Select a client first.' : !filteredProducts.length ? 'No mixes are available for the selected client.' : 'Select a mix.'}
>
<option value={0}>Select a product</option>
<option value={0}>Select a mix</option>
{#each filteredProducts as product}
<option value={product.product_id}>
{product.product_name} · {product.mix_name} · {product.unit_of_measure}
{product.product_name}
</option>
{/each}
</select>
@@ -338,8 +359,8 @@
{#if canEdit && selectedProduct}
<div class="calculation-note">
<strong>Source mix</strong>
<span>{selectedProduct.mix_name} totals {formatNumber(selectedProduct.mix_total_kg, 2)}kg. Scale factor = batch size / source mix total.</span>
<strong>Formula source</strong>
<span>{selectedProduct.product_name} totals {formatNumber(selectedProduct.mix_total_kg, 2)}kg. Scale factor = batch size / source formula total.</span>
</div>
{/if}
@@ -361,15 +382,19 @@
<MixCalculatorResultsPanel
preview={preview}
sessionNumber={initialSession?.session_number ?? null}
onOpenPdf={preview ? openPdf : null}
onPrint={preview ? printCurrent : null}
onDownloadPdf={preview ? downloadPdf : null}
/>
</section>
{#if preview}
<section class="print-only" aria-hidden="true">
<MixCalculatorPrintDocument session={preview} />
</section>
{#if printPdfUrl}
<iframe
bind:this={printFrame}
class="print-pdf-frame"
src={printPdfUrl}
title="Mix calculator PDF print frame"
onload={handlePrintFrameLoad}
></iframe>
{/if}
{/if}
@@ -636,33 +661,14 @@
}
}
.print-only {
display: none;
}
@media print {
:global(body) {
background: #fff !important;
margin: 0 !important;
}
:global(body *) {
visibility: hidden !important;
}
.print-only,
.print-only :global(*) {
visibility: visible !important;
}
.print-only {
display: block;
position: absolute;
inset: 0;
padding: 0;
background: #fff;
color: #1a2421;
font-family: inherit;
}
.print-pdf-frame {
position: fixed;
right: 0;
bottom: 0;
width: 1px;
height: 1px;
border: 0;
opacity: 0;
pointer-events: none;
}
</style>
@@ -1,32 +1,20 @@
<script lang="ts">
import { Download, Printer } from 'lucide-svelte';
import { formatDate, formatNumber } from '$lib/format';
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
let {
preview,
sessionNumber = null,
onOpenPdf = null,
onPrint = null,
onDownloadPdf = null
}: {
preview: MixCalculatorPreview | MixCalculatorSession | null;
sessionNumber?: string | null;
onOpenPdf?: (() => void) | null;
onPrint?: (() => void) | null;
onDownloadPdf?: (() => void) | null;
} = $props();
function formatDate(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium'
}).format(new Date(value));
}
function formatNumber(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return value.toFixed(digits);
}
</script>
<article class="result-card">
@@ -76,11 +64,11 @@
<strong>{preview.client_name}</strong>
</div>
<div>
<span>Product</span>
<span>Mix</span>
<strong>{preview.product_name}</strong>
</div>
<div>
<span>Mix source</span>
<span>Formula source</span>
<strong>{preview.mix_name}</strong>
</div>
<div>
@@ -111,7 +99,7 @@
</div>
<div class="output-actions">
<button class="primary-button" disabled={!onOpenPdf} type="button" onclick={() => onOpenPdf?.()}>
<button class="primary-button" disabled={!onPrint} type="button" onclick={() => onPrint?.()}>
<Printer size={18} strokeWidth={1.9} aria-hidden="true" />
Print
</button>
@@ -132,7 +120,7 @@
<span></span><span></span><span></span>
</div>
<strong>No calculation yet</strong>
<span>Choose a client, product, date, and batch size on the left, then click Calculate mix.</span>
<span>Choose a client, mix, date, and batch size on the left, then click Calculate mix.</span>
</div>
<div class="empty-shimmer-rows">
{#each [1,2,3,4,5] as _}
@@ -73,7 +73,7 @@
border: none;
border-radius: 0.7rem;
background: transparent;
color: var(--nav-item-color, #3a4a41);
color: var(--nav-item-color, var(--color-text-secondary));
font-size: var(--nav-item-size, 0.93rem);
font-weight: var(--nav-item-weight, 500);
text-align: left;
@@ -83,14 +83,14 @@
.nav-list a:hover,
.nav-button:hover {
background: var(--nav-item-hover-bg, var(--panel-soft));
color: var(--nav-item-hover-color, #304038);
background: var(--nav-item-hover-bg, var(--color-surface-hover));
color: var(--nav-item-hover-color, var(--color-text-primary));
}
.nav-list a.active,
.nav-button.active {
background: var(--nav-item-active-bg, var(--color-brand));
color: var(--nav-item-active-color, #fff);
color: var(--nav-item-active-color, var(--color-on-brand));
font-weight: var(--nav-item-active-weight, 600);
}
@@ -111,8 +111,8 @@
flex-shrink: 0;
padding: 0.08rem 0.4rem;
border-radius: 999px;
background: #fdf0d2;
color: #8a5a00;
background: var(--color-warning-tint);
color: var(--color-warning-text);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.06em;
@@ -122,8 +122,8 @@
.nav-list a.active .nav-badge,
.nav-button.active .nav-badge {
background: rgba(255, 255, 255, 0.92);
color: #8a5a00;
background: var(--color-warning-tint);
color: var(--color-warning-text);
}
.nav-icon {
@@ -133,18 +133,18 @@
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
color: var(--nav-icon-color, #6d7d74);
color: var(--nav-icon-color, var(--color-text-muted));
border-radius: 0.55rem;
transition: color 140ms ease;
}
.nav-list a:hover .nav-icon,
.nav-button:hover .nav-icon {
color: var(--nav-icon-hover-color, #304038);
color: var(--nav-icon-hover-color, var(--color-text-secondary));
}
.nav-list a.active .nav-icon,
.nav-button.active .nav-icon {
color: var(--nav-icon-active-color, #fff);
color: var(--nav-icon-active-color, var(--color-on-brand));
}
</style>
@@ -75,23 +75,23 @@
height: 100%;
min-height: calc(100vh - 8.5rem);
padding: 0;
background: color-mix(in srgb, var(--panel-soft) 72%, white);
background: color-mix(in srgb, var(--panel-soft) 60%, var(--color-bg-surface));
border-right: 1px solid var(--line);
overflow-y: auto;
--nav-section-label-color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
--nav-section-label-color: var(--color-text-muted);
--nav-section-label-size: 0.66rem;
--nav-section-label-spacing: 0.14em;
--nav-item-color: #66756d;
--nav-item-color: var(--color-text-secondary);
--nav-item-size: 0.88rem;
--nav-item-weight: 450;
--nav-item-hover-bg: color-mix(in srgb, var(--panel) 72%, transparent);
--nav-item-hover-color: #425148;
--nav-item-active-bg: color-mix(in srgb, var(--color-brand) 7%, transparent);
--nav-item-active-color: #22352d;
--nav-item-hover-bg: var(--color-surface-hover);
--nav-item-hover-color: var(--color-text-primary);
--nav-item-active-bg: color-mix(in srgb, var(--color-brand) 11%, transparent);
--nav-item-active-color: var(--color-brand-hover);
--nav-item-active-weight: 560;
--nav-item-active-marker: color-mix(in srgb, var(--color-brand) 28%, transparent);
--nav-icon-color: #8a9790;
--nav-icon-hover-color: #607067;
--nav-item-active-marker: color-mix(in srgb, var(--color-brand) 32%, transparent);
--nav-icon-color: var(--color-text-muted);
--nav-icon-hover-color: var(--color-text-secondary);
--nav-icon-active-color: var(--color-brand);
}
@@ -1,14 +1,19 @@
<script lang="ts">
import { LogOut, Settings } from 'lucide-svelte';
import { ChevronDown, LogOut, Settings } from 'lucide-svelte';
import type { ComponentType } from 'svelte';
import AppNavSection from '$lib/components/navigation/AppNavSection.svelte';
import { matchesRoute, type FooterLink, type NavItem } from '$lib/navigation/client-navigation';
import {
groupHasActiveChild,
matchesRoute,
type FooterLink,
type NavEntry,
type NavItem
} from '$lib/navigation/client-navigation';
let {
brandHref,
currentPath,
primaryItems,
workingDocumentItems,
entries,
footerItems,
appVersion,
currentYear,
@@ -18,8 +23,7 @@
}: {
brandHref: string;
currentPath: string;
primaryItems: NavItem[];
workingDocumentItems: NavItem[];
entries: NavEntry[];
footerItems: FooterLink[];
appVersion: string;
currentYear: number;
@@ -27,76 +31,156 @@
onOpenSettings: () => void;
onSignOut: () => void;
} = $props();
// ── Collapse state ──────────────────────────────────────────────
// Smart auto-expand: the group holding the current page opens itself, other
// groups stay exactly as the user left them, and the whole map survives a
// reload through sessionStorage. Multiple groups may be open at once.
const STORAGE_KEY = 'hsf:nav:open-groups';
function restoreOpenState(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
try {
return JSON.parse(window.sessionStorage.getItem(STORAGE_KEY) ?? '{}');
} catch {
return {};
}
}
let openGroups = $state<Record<string, boolean>>(restoreOpenState());
let lastAutoExpanded = $state<string | null>(null);
function persistOpenState() {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(openGroups));
} catch {
// Private-mode storage failures shouldn't break navigation.
}
}
const activeGroupId = $derived.by(() => {
for (const entry of entries) {
if (entry.kind === 'group' && groupHasActiveChild(entry.group, currentPath)) {
return entry.group.id;
}
}
return null;
});
// Open the active group once each time it changes. Because this only fires on
// a *change* of activeGroupId, a user who manually closes the group they're
// standing in won't have it reopened under them.
$effect(() => {
const id = activeGroupId;
if (id && lastAutoExpanded !== id) {
if (!openGroups[id]) {
openGroups[id] = true;
persistOpenState();
}
lastAutoExpanded = id;
}
});
const isOpen = (id: string) => openGroups[id] ?? false;
function toggleGroup(id: string) {
openGroups[id] = !isOpen(id);
persistOpenState();
}
const moduleCount = $derived.by(() =>
entries.reduce((count, entry) => count + (entry.kind === 'item' ? 1 : entry.group.children.length), 0)
);
</script>
{#snippet leafLink(item: NavItem, showIcon: boolean)}
{@const Icon = item.icon}
<a class="rail-row" class:active={matchesRoute(item.href, currentPath)} href={item.href}>
{#if showIcon && Icon}
<span class="rail-icon"><Icon size={18} strokeWidth={1.75} /></span>
{/if}
<span class="rail-text">{item.label}</span>
{#if item.badge}<span class="rail-badge">{item.badge}</span>{/if}
</a>
{/snippet}
{#snippet actionRow(label: string, Icon: ComponentType, active: boolean, onSelect: () => void)}
{@const RowIcon = Icon}
<button type="button" class="rail-row" class:active onclick={onSelect}>
<span class="rail-icon"><RowIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{label}</span>
</button>
{/snippet}
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href={brandHref}>
<img class="sidebar-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
<span class="brand-kicker">Hunter App</span>
<span class="brand-wordmark">Hunter Premium Produce</span>
<span class="brand-subtitle">Operations workspace</span>
</a>
<span class="module-pill">{moduleCount} modules</span>
</div>
<div class="sidebar-body">
<AppNavSection
label="Modules"
ariaLabel="Client navigation"
items={primaryItems.map((item) => ({
label: item.label,
href: item.href,
icon: item.icon,
badge: item.badge,
active: matchesRoute(item.href, currentPath)
}))}
/>
<div class="rail-scroll">
<div class="rail-section-head">
<p class="rail-section-label">Modules</p>
<span class="rail-section-count">{moduleCount}</span>
</div>
{#if workingDocumentItems.length}
<AppNavSection
label="Working Docs"
ariaLabel="Working document pages"
items={workingDocumentItems.map((item) => ({
label: item.label,
href: item.href,
icon: item.icon,
active: matchesRoute(item.href, currentPath)
}))}
/>
{/if}
<nav class="rail-nav" aria-label="Workspace navigation">
{#each entries as entry}
{#if entry.kind === 'item'}
{@render leafLink(entry.item, true)}
{:else}
{@const group = entry.group}
{@const GroupIcon = group.icon}
{@const groupActive = groupHasActiveChild(group, currentPath)}
{@const open = isOpen(group.id)}
<div class="rail-group">
<button
type="button"
class="rail-row rail-group-toggle"
class:within-active={groupActive && !open}
aria-expanded={open}
onclick={() => toggleGroup(group.id)}
>
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{group.label}</span>
<span class="rail-group-meta">
<span class="rail-group-count">{group.children.length}</span>
<span class="rail-chevron" class:open aria-hidden="true">
<ChevronDown size={15} strokeWidth={2} />
</span>
</span>
</button>
{#if footerItems.length}
<AppNavSection
label="More"
ariaLabel="Workspace shortcuts"
items={footerItems.map((item) => ({
label: item.label,
href: item.href,
icon: item.icon
}))}
/>
{/if}
{#if open}
<div class="rail-children">
{#each group.children as child}
{@render leafLink(child, false)}
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</nav>
</div>
<div class="sidebar-meta">
<AppNavSection
ariaLabel="Account actions"
items={[
...(canOpenSettings
? [
{
label: 'Settings',
icon: Settings,
active: currentPath.startsWith('/settings'),
onSelect: onOpenSettings,
type: 'button' as const
}
]
: []),
{
label: 'Logout',
icon: LogOut,
onSelect: onSignOut,
type: 'button' as const
}
]}
/>
<div class="rail-nav">
{#each footerItems as item}
{@render leafLink(item as NavItem, true)}
{/each}
{#if canOpenSettings}
{@render actionRow('Settings', Settings, currentPath.startsWith('/settings'), onOpenSettings)}
{/if}
{@render actionRow('Logout', LogOut, false, onSignOut)}
</div>
<div class="sidebar-meta-foot">
<div class="sidebar-meta-top">
@@ -119,18 +203,28 @@
</aside>
<style>
/* Light monochrome rail with a dark selected pill. The rail keeps its own palette
via the --sidebar-* tokens, independent of the content theme. */
.sidebar {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 1.1rem 0.85rem 0.85rem;
background: var(--panel);
border-right: 1px solid var(--line);
gap: 0.75rem;
padding: 1rem 0.8rem 0.85rem;
background: var(--sidebar-bg);
border-right: 1px solid var(--sidebar-border);
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
scrollbar-width: thin;
overflow: hidden;
}
.rail-section-label {
margin: 0;
color: var(--sidebar-text-muted);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.sidebar-body {
@@ -138,45 +232,301 @@
display: flex;
flex: 1;
flex-direction: column;
gap: 0.7rem;
gap: 0.75rem;
}
.rail-scroll {
min-height: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 0.55rem;
overflow-y: auto;
scrollbar-width: thin;
padding-right: 0.15rem;
}
.rail-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0 0.5rem;
}
.rail-section-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
height: 1.35rem;
padding: 0 0.42rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
color: var(--sidebar-text-muted);
font-size: 0.7rem;
font-weight: 700;
}
.brand-row {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 0.68rem;
padding: 0.2rem 0.35rem 0.95rem;
border-bottom: 1px solid var(--line);
gap: 0.9rem;
padding: 0.15rem 0.35rem 0.95rem;
border-bottom: 1px solid var(--sidebar-border);
}
.brand {
display: block;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.16rem;
min-width: 0;
padding: 0.08rem 0 0.1rem;
}
.brand-kicker {
color: var(--sidebar-text-muted);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.brand-wordmark {
font-size: 1rem;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--sidebar-text-strong);
}
.brand-subtitle {
color: var(--sidebar-text-muted);
font-size: 0.8rem;
line-height: 1.3;
}
.module-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.34rem 0.58rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-active-bg) 10%, transparent);
color: var(--sidebar-active-bg);
font-size: 0.7rem;
font-weight: 700;
white-space: nowrap;
}
/* ── Navigation rows ─────────────────────────────────────────── */
.rail-nav {
display: grid;
gap: 0.18rem;
}
.rail-row {
position: relative;
display: flex;
align-items: center;
gap: 0.72rem;
width: 100%;
min-height: 2.8rem;
padding: 0.62rem 0.72rem;
border: none;
border-radius: 0.9rem;
background: transparent;
color: var(--sidebar-text);
font-size: 0.92rem;
font-weight: 500;
letter-spacing: -0.01em;
text-align: left;
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease;
}
.sidebar-logo {
width: min(100%, 15.5rem);
max-width: none;
height: auto;
display: block;
object-fit: contain;
.rail-row:hover {
background: var(--sidebar-hover);
color: var(--sidebar-text-strong);
}
.rail-row:hover .rail-icon {
color: var(--sidebar-text-strong);
}
.rail-row.active,
.rail-row.active:hover {
background: var(--sidebar-active-bg);
color: var(--sidebar-active-text);
font-weight: 600;
}
.rail-row.active .rail-text,
.rail-row.active:hover .rail-text {
color: var(--sidebar-active-text);
}
.rail-row.active .rail-icon {
color: var(--sidebar-active-text);
}
.rail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
color: var(--sidebar-icon);
transition: color 140ms ease;
}
.rail-text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-badge {
margin-left: auto;
flex-shrink: 0;
padding: 0.12rem 0.42rem;
border-radius: 999px;
border: 1px solid var(--sidebar-border);
background: transparent;
color: var(--sidebar-text-muted);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
line-height: 1.5;
}
.rail-row.active .rail-badge {
border-color: color-mix(in srgb, var(--sidebar-active-text) 26%, transparent);
color: var(--sidebar-active-text);
background: color-mix(in srgb, var(--sidebar-active-text) 12%, transparent);
}
.rail-group {
display: grid;
gap: 0.2rem;
}
.rail-group-meta {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.42rem;
}
.rail-group-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.2rem;
padding: 0 0.32rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
color: var(--sidebar-text-muted);
font-size: 0.66rem;
font-weight: 700;
line-height: 1;
}
.rail-group-toggle.within-active {
color: var(--sidebar-text-strong);
font-weight: 600;
}
.rail-group-toggle.within-active .rail-group-count {
color: var(--sidebar-text-strong);
}
.rail-group-toggle.within-active .rail-icon,
.rail-group-toggle.within-active .rail-chevron {
color: var(--sidebar-text-strong);
}
.rail-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--sidebar-icon);
transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.rail-chevron.open {
transform: rotate(180deg);
}
.rail-children {
position: relative;
display: grid;
gap: 0.18rem;
margin: 0 0 0.15rem;
padding-left: 1.2rem;
animation: rail-reveal 170ms cubic-bezier(0.22, 1, 0.36, 1);
}
.rail-children::before {
content: '';
position: absolute;
top: 0.34rem;
bottom: 0.34rem;
left: 0.58rem;
width: 1px;
background: var(--sidebar-border);
}
.rail-children .rail-row {
min-height: 2.45rem;
padding: 0.48rem 0.62rem 0.48rem 0.82rem;
font-size: 0.88rem;
border-radius: 0.8rem;
}
@keyframes rail-reveal {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.rail-chevron,
.rail-children {
transition: none;
animation: none;
}
}
/* ── Footer / meta ───────────────────────────────────────────── */
.sidebar-meta {
margin-top: auto;
display: grid;
gap: 0.7rem;
padding-top: 1rem;
gap: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--sidebar-border);
flex-shrink: 0;
}
.sidebar-meta-foot {
display: grid;
gap: 0.55rem;
padding: 0.8rem 0.55rem 0;
border-top: 1px solid var(--line);
color: var(--muted);
padding: 0.7rem 0.5rem 0;
border-top: 1px solid var(--sidebar-border);
color: var(--sidebar-text-muted);
font-size: 0.76rem;
}
@@ -197,7 +547,7 @@
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--muted);
color: var(--sidebar-text-muted);
white-space: nowrap;
}
@@ -209,7 +559,7 @@
.powered-by strong {
font-size: 0.76rem;
font-weight: 600;
color: #5e6c64;
color: var(--sidebar-text);
}
.lean101-logo {
@@ -225,19 +575,18 @@
align-items: center;
gap: 0.45rem;
padding: 0.24rem 0.56rem;
border: 1px solid var(--line);
border: 1px solid var(--sidebar-border);
border-radius: 999px;
background: var(--panel-soft);
color: #5e6c64;
background: color-mix(in srgb, var(--sidebar-text-strong) 4%, transparent);
color: var(--sidebar-text);
font-size: 0.72rem;
font-weight: 600;
}
.meta-label {
color: var(--muted);
color: var(--sidebar-text-muted);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
</style>
@@ -1,6 +1,7 @@
<script lang="ts">
import { Settings } from 'lucide-svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
import type { AppSession } from '$lib/session';
import type { Crumb } from '$lib/navigation/client-navigation';
@@ -36,6 +37,9 @@
<header class="topbar">
<div class="topbar-start">
<a class="topbar-brand" href="/" aria-label="Hunter Premium Produce home">
<img src="/logo-hsf.png" alt="Hunter Premium Produce" />
</a>
<div class="topbar-copy">
<nav class="breadcrumbs" aria-label="Breadcrumb">
{#each breadcrumbs as crumb, index}
@@ -60,6 +64,8 @@
{/if}
<div class="topbar-actions">
<ThemeToggle />
<div class="menu-wrap user-menu-wrap">
<button aria-expanded={userMenuOpen} class="user-trigger" type="button" onclick={onToggleUserMenu}>
<span class="user-avatar-wrap">
@@ -127,8 +133,22 @@
.topbar-start {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 0.82rem;
align-items: center;
gap: 0.9rem;
}
.topbar-brand {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding-right: 0.9rem;
border-right: 1px solid var(--color-border);
}
.topbar-brand img {
height: 2rem;
width: auto;
display: block;
}
.topbar-copy h1 {
@@ -163,7 +183,7 @@
}
.breadcrumb-sep {
color: #b9c5be;
color: var(--color-text-muted);
font-size: 0.78rem;
}
@@ -176,7 +196,7 @@
:global(.topbar-search) {
width: 100%;
min-height: 2.75rem;
background: color-mix(in srgb, var(--panel-soft) 68%, white);
background: color-mix(in srgb, var(--panel-soft) 60%, var(--color-bg-surface));
}
.topbar-actions {
@@ -205,10 +225,10 @@
align-items: center;
gap: 0.72rem;
padding: 0.56rem 0.76rem;
border: 1px solid var(--line);
border: 1px solid var(--color-border);
border-radius: 0.96rem;
background: var(--panel-soft);
color: #304038;
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
}
@@ -225,8 +245,8 @@
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
background: var(--color-brand);
color: var(--color-on-brand);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
@@ -240,16 +260,16 @@
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
border: 1.5px solid var(--panel-soft);
background: #b4c0ba;
border: 1.5px solid var(--color-bg-surface);
background: var(--color-text-muted);
}
.user-status-dot.live {
background: #4ade80;
background: var(--color-success);
}
.user-status-dot.idle {
background: #c08b3d;
background: var(--color-warning);
}
.user-menu-avatar {
@@ -260,8 +280,8 @@
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
background: var(--color-brand);
color: var(--color-on-brand);
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.02em;
@@ -329,8 +349,8 @@
.chevron {
width: 0.54rem;
height: 0.54rem;
border-right: 2px solid #7a8c82;
border-bottom: 2px solid #7a8c82;
border-right: 2px solid var(--color-text-muted);
border-bottom: 2px solid var(--color-text-muted);
transform: rotate(45deg);
transition: transform 140ms ease;
}
@@ -350,16 +370,15 @@
padding: 0.4rem;
border: 1px solid var(--color-border);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
backdrop-filter: blur(10px);
background: var(--color-bg-elevated);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.menu-panel a,
.menu-panel button {
padding: 0.72rem 0.78rem;
border-radius: 0.78rem;
color: #304038;
color: var(--color-text-primary);
text-align: left;
background: transparent;
border: none;
@@ -397,6 +416,14 @@
padding: 0.72rem 1rem;
}
.topbar-brand {
padding-right: 0.6rem;
}
.topbar-brand img {
height: 1.6rem;
}
.user-trigger {
min-width: auto;
width: 100%;
@@ -35,18 +35,18 @@
}
.search-box:hover {
border-color: color-mix(in srgb, var(--color-brand) 22%, var(--line));
background: #fff;
border-color: color-mix(in srgb, var(--color-brand) 24%, var(--line));
background: var(--color-bg-surface);
}
.search-box:focus-visible {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 16%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
.search-placeholder {
color: #93a098;
color: var(--color-text-muted);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -58,7 +58,7 @@
display: inline-block;
width: 0.82rem;
height: 0.82rem;
border: 2px solid #98a59d;
border: 2px solid var(--color-text-muted);
border-radius: 999px;
}
@@ -70,7 +70,7 @@
width: 0.42rem;
height: 2px;
border-radius: 999px;
background: #98a59d;
background: var(--color-text-muted);
transform: rotate(45deg);
}
@@ -79,7 +79,7 @@
border: 1px solid var(--line-strong);
border-radius: 0.42rem;
color: var(--muted);
background: #fff;
background: var(--color-bg-surface);
font-size: 0.76rem;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { formatDate, formatLocaleNumber, formatNumber, toNum } from './format';
describe('format utilities', () => {
it('coerces numeric input safely', () => {
expect(toNum(' 12.5 ')).toBe(12.5);
expect(toNum(3)).toBe(3);
expect(toNum('')).toBeNull();
expect(toNum(Number.NaN)).toBeNull();
expect(toNum('not a number')).toBeNull();
});
it('formats fixed and locale numbers', () => {
expect(formatNumber(12.345, 2)).toBe('12.35');
expect(formatNumber(null, 2)).toBe('N/A');
expect(formatLocaleNumber(1234.56, 1, 'en-AU')).toBe('1,234.6');
expect(formatLocaleNumber(undefined, 1)).toBe('-');
});
it('formats dates with a fallback for empty values', () => {
expect(formatDate(null)).toBe('-');
expect(formatDate('not-a-date')).toBe('not-a-date');
expect(formatDate('2026-06-04', { day: '2-digit', month: 'short', year: 'numeric' }, 'en-AU')).toContain('2026');
});
});
+39
View File
@@ -0,0 +1,39 @@
export function toNum(value: unknown): number | null {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
const trimmed = String(value).trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
export function formatNumber(value: number | null | undefined, digits = 2, fallback = 'N/A') {
if (value === null || value === undefined) return fallback;
return value.toFixed(digits);
}
export function formatLocaleNumber(
value: number | null | undefined,
digits = 0,
locale = 'en-AU',
fallback = '-'
) {
if (value === null || value === undefined) return fallback;
return value.toLocaleString(locale, { maximumFractionDigits: digits });
}
export function formatDate(
value: string | null | undefined,
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium' },
locale = 'en-NZ',
fallback = '-'
) {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(locale, options).format(date);
}
@@ -1,7 +1,9 @@
import {
BadgeDollarSign,
Calculator,
ClipboardPenLine,
Gauge,
Layers,
LayoutDashboard,
ShieldCheck,
TrendingUp
@@ -37,6 +39,23 @@ export type FooterLink = {
icon: ComponentType;
};
/**
* A collapsible family of related modules in the primary rail. Groups keep the
* top level short as more modules ship: a new costing tool becomes another child
* here rather than another peer in a flat stack.
*/
export type NavGroup = {
id: string;
label: string;
icon: ComponentType;
children: NavItem[];
};
/** The rail is a sequence of standalone items and collapsible groups. */
export type NavEntry =
| { kind: 'item'; item: NavItem }
| { kind: 'group'; group: NavGroup };
export type Crumb = {
label: string;
href?: string;
@@ -58,9 +77,18 @@ export const mixCalculatorItem: NavItem = {
moduleKey: 'mix_calculator'
};
export const productCostingItem: NavItem = {
href: '/product-costing',
label: 'Product Costing',
shortLabel: 'PC',
icon: BadgeDollarSign,
moduleKey: 'products',
badge: 'Alpha'
};
export const editorItem: NavItem = {
href: '/editor',
label: 'Editor',
label: 'Mix Editor',
shortLabel: 'ED',
icon: ClipboardPenLine,
moduleKey: 'products',
@@ -100,6 +128,7 @@ export const accessControlItem: NavItem = {
export const clientNavigationItems: NavItem[] = [
dashboardItem,
mixCalculatorItem,
productCostingItem,
throughputItem,
editorItem,
accessControlItem
@@ -108,9 +137,15 @@ export const clientNavigationItems: NavItem[] = [
export const footerLinks: FooterLink[] = [];
export const baseSearchItems: SearchItem[] = [
{
href: '/product-costing',
label: 'Open Product Costing',
description: 'Maintain product costing records, assumptions, and calculated pricing.',
keywords: 'alpha product costing pricing finished delivered distributor wholesale margin spreadsheet'
},
{
href: '/editor',
label: 'Open Editor',
label: 'Open Mix Editor',
description: 'Edit client, product, and mix naming from one table.',
keywords: 'editor products mixes clients names bulk table phf horse manning'
},
@@ -162,6 +197,50 @@ export const baseSearchItems: SearchItem[] = [
},
];
/**
* Assemble the grouped primary rail from already access-filtered items.
* Callers pass only the modules the current session may see; empty families
* collapse away so a role with one costing tool never gets an empty group.
*
* Workflow-family layout: Dashboard, then a "Costing" group (the calculator,
* costing, editor, and master tools), then Operations and Insights modules at
* the top level until each grows into a family of its own.
*/
export function buildClientNavEntries(visible: {
dashboard?: NavItem | null;
costing: NavItem[];
throughput?: NavItem | null;
reporting?: NavItem | null;
}): NavEntry[] {
const entries: NavEntry[] = [];
if (visible.dashboard) {
entries.push({ kind: 'item', item: visible.dashboard });
}
if (visible.costing.length) {
entries.push({
kind: 'group',
group: { id: 'costing', label: 'Costing', icon: Layers, children: visible.costing }
});
}
if (visible.throughput) {
entries.push({ kind: 'item', item: visible.throughput });
}
if (visible.reporting) {
entries.push({ kind: 'item', item: visible.reporting });
}
return entries;
}
/** True when any of a group's children matches the current route. */
export function groupHasActiveChild(group: NavGroup, pathname: string) {
return group.children.some((child) => matchesRoute(child.href, pathname));
}
export function matchesRoute(href: string, pathname: string) {
return href === '/' ? pathname === '/' : pathname.startsWith(href);
}
@@ -185,8 +264,12 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
return [...crumbs, { label: 'Mix Calculator' }];
}
if (pathname.startsWith('/product-costing')) {
return [...crumbs, { label: 'Product Costing' }];
}
if (pathname.startsWith('/editor')) {
return [...crumbs, { label: 'Editor' }];
return [...crumbs, { label: 'Mix Editor' }];
}
if (pathname.startsWith('/mixes')) {
@@ -202,6 +285,7 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
const sectionMap: Record<string, string> = {
'/raw-materials': 'Raw Materials',
'/product-costing': 'Product Costing',
'/products': 'Products',
'/scenarios': 'Scenarios',
'/client-access': 'Client Access',
+541
View File
@@ -0,0 +1,541 @@
/* ============================================================================
Theme + design tokens (single source of truth)
----------------------------------------------------------------------------
Light is the default. Dark is opted into via <html data-theme="dark">, set
before first paint by the inline script in app.html and kept in sync by
$lib/theme.ts. Every colour the app uses resolves from a token here, so a
surface that reads from tokens themes automatically in both modes.
Scene that forced the dark palette: an operations lead reconciling pasta
production costs late in a dim back-office, wanting the glare off a white
screen without losing the soft-green brand identity.
============================================================================ */
:root {
color-scheme: light;
/* Brand: green-forward (Weavy). Emerald carries primary actions,
active content states, charts, and positive deltas. */
--color-brand: oklch(0.56 0.125 162); /* primary green button */
--color-brand-hover: oklch(0.49 0.115 162);
--color-brand-tint: oklch(0.95 0.04 162);
--color-on-brand: oklch(0.99 0.012 162);
--color-secondary: oklch(0.45 0.01 240); /* neutral secondary */
/* ── Accent (brighter emerald for data / positive emphasis) ── */
--color-accent: oklch(0.65 0.15 162);
--color-accent-hover: oklch(0.56 0.13 162);
--color-accent-tint: oklch(0.95 0.05 162);
/* ── Content surfaces: neutral light-gray canvas, white cards ── */
--color-bg-app: oklch(0.966 0.002 240);
--color-bg-surface: oklch(0.998 0.001 240);
--color-bg-elevated: oklch(0.99 0.0015 240);
--color-surface-hover: oklch(0.955 0.004 240);
--color-surface-selected: color-mix(in srgb, var(--color-brand) 10%, var(--color-bg-surface));
/* ── Borders ────────────────────────────────────────────── */
--color-border: oklch(0.92 0.005 240);
--color-divider: oklch(0.94 0.004 240);
/* ── Text (neutral) ─────────────────────────────────────── */
--color-text-primary: oklch(0.25 0.006 240);
--color-text-secondary: oklch(0.45 0.008 240);
--color-text-muted: oklch(0.6 0.01 240);
/* Sidebar: light monochrome rail with the current item shown as the
selected pill. Shared across themes so navigation stays consistent. */
--sidebar-bg: oklch(0.985 0.001 240);
--sidebar-hover: oklch(0.952 0.003 240);
--sidebar-active-bg: #3290d9;
--sidebar-active-text: var(--color-on-brand);
--sidebar-border: oklch(0.9 0.004 240);
--sidebar-text: oklch(0.34 0.006 240);
--sidebar-text-strong: oklch(0.16 0.004 240);
--sidebar-text-muted: oklch(0.56 0.008 240);
--sidebar-icon: oklch(0.42 0.006 240);
--sidebar-logo-bg: oklch(0.98 0.003 240);
/* ── Semantic ───────────────────────────────────────────── */
--color-success: oklch(0.66 0.16 162); /* emerald, cohesive with accent */
--color-warning: oklch(0.66 0.12 78);
--color-error: oklch(0.58 0.2 25);
--color-info: oklch(0.55 0.13 245);
--color-success-text: oklch(0.52 0.14 162);
--color-success-tint: oklch(0.95 0.05 164);
--color-warning-text: oklch(0.45 0.11 69);
--color-warning-tint: oklch(0.96 0.045 78);
--color-info-tint: oklch(0.965 0.025 245);
/* Brand-literal deep green: kept for legacy success / positive
text and tints. Solid brand chips point at --color-brand. */
--green-deep: oklch(0.42 0.13 150);
/* ── Radii / spacing (theme-independent) ────────────────── */
--radius-panel: 0.9rem;
--radius-control: 0.68rem;
--radius-row: 0.78rem;
--space-page: 1.25rem;
--space-card: 1.15rem;
--shadow: none; /* flat by design: separate with borders, not shadows */
/* Legacy aliases (resolve through the tokens above, so they
re-theme automatically when the tokens are overridden).
--green maps to the accent so existing green usages stay green. */
--bg: var(--color-bg-app);
--panel: var(--color-bg-surface);
--panel-soft: var(--color-bg-app);
--line: var(--color-border);
--line-strong: var(--color-border);
--text: var(--color-text-primary);
--muted: var(--color-text-muted);
--green: var(--color-accent);
--green-soft: var(--color-success-tint);
--blue-soft: var(--color-info-tint);
}
:root[data-theme='dark'] {
color-scheme: dark;
/* ── Brand: green-forward, brightened for dark ─────────────── */
--color-brand: oklch(0.62 0.13 162);
--color-brand-hover: oklch(0.7 0.13 162);
--color-brand-tint: oklch(0.32 0.06 162);
--color-on-brand: oklch(0.99 0.012 162);
--color-secondary: oklch(0.72 0.01 240);
/* ── Accent (emerald) ───────────────────────────────────── */
--color-accent: oklch(0.72 0.15 162);
--color-accent-hover: oklch(0.8 0.14 162);
--color-accent-tint: oklch(0.33 0.06 162);
/* ── Surfaces (neutral dark) ────────────────────────────── */
--color-bg-app: oklch(0.17 0.004 240);
--color-bg-surface: oklch(0.215 0.005 240);
--color-bg-elevated: oklch(0.26 0.006 240);
--color-surface-hover: oklch(0.27 0.006 240);
--color-surface-selected: color-mix(in srgb, var(--color-brand) 14%, var(--color-bg-surface));
/* ── Borders ────────────────────────────────────────────── */
--color-border: oklch(0.32 0.006 240);
--color-divider: oklch(0.28 0.005 240);
/* ── Text (neutral) ─────────────────────────────────────── */
--color-text-primary: oklch(0.96 0.003 240);
--color-text-secondary: oklch(0.78 0.006 240);
--color-text-muted: oklch(0.62 0.008 240);
/* ── Semantic ───────────────────────────────────────────── */
--color-success: oklch(0.75 0.16 162);
--color-warning: oklch(0.8 0.12 80);
--color-error: oklch(0.7 0.18 25);
--color-info: oklch(0.72 0.12 240);
--color-success-text: oklch(0.84 0.14 162);
--color-success-tint: oklch(0.32 0.06 162);
--color-warning-text: oklch(0.85 0.1 80);
--color-warning-tint: oklch(0.32 0.05 78);
--color-info-tint: oklch(0.3 0.05 240);
--green-deep: oklch(0.7 0.15 150);
}
/* ============================================================================
Base
============================================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background: var(--color-bg-app);
color: var(--color-text-primary);
font-family: 'Inter', 'Segoe UI', sans-serif;
font-size: 14px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Inter', 'Segoe UI', sans-serif;
letter-spacing: -0.03em;
}
button,
input,
select,
textarea {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-brand) 42%, transparent);
outline-offset: 2px;
}
/* ============================================================================
Shared product surfaces (used across every route, so they live here rather
than in any one shell component)
============================================================================ */
.ui-stack {
display: grid;
gap: var(--space-page);
}
.ui-panel,
.ui-metric-card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-panel);
box-shadow: var(--shadow);
}
.ui-panel {
padding: var(--space-card);
}
.ui-panel-soft {
background: var(--panel-soft);
border: 1px solid var(--color-border);
border-radius: var(--radius-row);
}
.ui-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
margin-bottom: 1rem;
}
.ui-section-heading h3,
.ui-section-heading h4 {
margin: 0.18rem 0 0;
font-size: 1.06rem;
font-weight: 700;
letter-spacing: 0;
}
.ui-eyebrow {
margin: 0;
color: var(--color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ui-muted {
color: var(--color-text-muted);
}
.ui-metric-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.ui-metric-card {
padding: 1.05rem 1.1rem;
}
.ui-metric-card span {
display: block;
color: var(--color-text-muted);
font-size: 0.84rem;
}
.ui-metric-card strong {
display: block;
margin: 0.5rem 0 0.28rem;
font-size: 1.75rem;
font-weight: 700;
}
.ui-metric-card p {
margin: 0;
color: var(--color-text-muted);
}
.ui-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.6rem;
padding: 0.72rem 0.9rem;
border-radius: var(--radius-control);
font-weight: 600;
cursor: pointer;
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), color 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ui-button.primary {
border: 1px solid var(--color-brand);
color: var(--color-on-brand);
background: var(--color-brand);
}
.ui-button.primary:hover:not(:disabled) {
background: var(--color-brand-hover);
border-color: var(--color-brand-hover);
}
.ui-button.secondary {
border: 1px solid var(--color-border);
color: var(--color-text-primary);
background: var(--color-bg-surface);
}
.ui-button.secondary:hover:not(:disabled) {
background: var(--color-surface-hover);
}
.ui-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ui-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
border-radius: 999px;
padding: 0.4rem 0.74rem;
font-size: 0.82rem;
font-weight: 600;
text-transform: capitalize;
white-space: nowrap;
}
.ui-pill.positive {
color: var(--color-success-text);
background: var(--color-success-tint);
}
.ui-pill.warning {
color: var(--color-warning-text);
background: var(--color-warning-tint);
}
.ui-pill.neutral {
color: var(--color-text-secondary);
background: color-mix(in srgb, var(--panel-soft) 74%, var(--color-bg-surface));
}
.ui-table-wrap {
overflow-x: auto;
}
.ui-table {
width: 100%;
min-width: 48rem;
border-collapse: separate;
border-spacing: 0 0.65rem;
}
.ui-table th,
.ui-table td {
padding: 0.9rem 0.95rem;
text-align: left;
white-space: nowrap;
}
.ui-table th {
color: var(--color-text-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ui-table tbody td {
background: var(--panel-soft);
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
}
.ui-table tbody td:first-child {
border-left: 1px solid var(--color-border);
border-radius: var(--radius-row) 0 0 var(--radius-row);
}
.ui-table tbody td:last-child {
border-right: 1px solid var(--color-border);
border-radius: 0 var(--radius-row) var(--radius-row) 0;
}
.ui-table-identity {
display: flex;
align-items: center;
gap: 0.74rem;
min-width: 0;
}
.ui-row-mark {
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.76rem;
color: var(--color-on-brand);
background: var(--color-brand);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
}
.ui-table-identity strong,
.ui-number-block strong {
display: block;
font-size: 0.94rem;
}
.ui-table-identity span,
.ui-number-block span {
display: block;
margin-top: 0.16rem;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.ui-number-block {
display: grid;
gap: 0.08rem;
}
.ui-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.ui-form-grid.compact {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.ui-field {
display: grid;
gap: 0.36rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
font-weight: 600;
}
.ui-field input,
.ui-field textarea,
.ui-field select {
width: 100%;
padding: 0.82rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--panel-soft);
color: var(--color-text-primary);
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ui-field input:focus,
.ui-field textarea:focus,
.ui-field select:focus {
outline: none;
border-color: var(--color-brand);
background: var(--color-bg-surface);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
}
@media (max-width: 980px) {
.ui-metric-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.ui-section-heading {
flex-direction: column;
align-items: flex-start;
}
.ui-table {
min-width: 0;
border-spacing: 0;
}
.ui-table,
.ui-table thead,
.ui-table tbody,
.ui-table tr,
.ui-table td {
display: block;
width: 100%;
}
.ui-table thead {
display: none;
}
.ui-table tbody {
display: grid;
gap: 0.85rem;
}
.ui-table tbody tr {
padding: 0.3rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-row);
background: var(--panel-soft);
}
.ui-table tbody td {
padding: 0.76rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
.ui-table tbody td:first-child,
.ui-table tbody td:last-child {
border: none;
border-radius: 0;
}
.ui-table tbody td + td {
border-top: 1px solid var(--color-border);
}
.ui-table tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ui-form-grid,
.ui-form-grid.compact {
grid-template-columns: 1fr;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export type ThemePreference = 'light' | 'dark' | 'system';
export type ResolvedTheme = 'light' | 'dark';
const STORAGE_KEY = 'theme';
function systemTheme(): ResolvedTheme {
return browser && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function resolve(pref: ThemePreference): ResolvedTheme {
return pref === 'system' ? systemTheme() : pref;
}
function readPreference(): ThemePreference {
if (!browser) return 'system';
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
}
function applyResolved(theme: ResolvedTheme) {
if (browser) {
document.documentElement.dataset.theme = theme;
}
}
/** The user's stored choice (may be 'system'). */
export const themePreference = writable<ThemePreference>(readPreference());
/** The theme actually painted right now ('system' collapsed to light/dark). */
export const resolvedTheme = writable<ResolvedTheme>(resolve(readPreference()));
if (browser) {
themePreference.subscribe((pref) => {
window.localStorage.setItem(STORAGE_KEY, pref);
const next = resolve(pref);
resolvedTheme.set(next);
applyResolved(next);
});
// Follow the OS only while the user is on 'system'.
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (readPreference() === 'system') {
const next = systemTheme();
resolvedTheme.set(next);
applyResolved(next);
}
});
}
/** Flip between light and dark, committing to an explicit preference. */
export function toggleTheme() {
themePreference.update((pref) => (resolve(pref) === 'dark' ? 'light' : 'dark'));
}
+100
View File
@@ -208,6 +208,73 @@ export type ProductCostBreakdown = {
inputs?: Record<string, unknown>;
};
export type ProductCostingItem = {
id: number;
tenant_id: string;
client_category: string;
item_id: string | null;
product_name: string;
mix_product_name: string;
unit_type: string;
own_bag: string | null;
unit_kg: number | null;
items_per_pallet: number | null;
bagging_process: string | null;
manual_distributor_margin: number | null;
manual_wholesale_margin: number | null;
cleaned_product_cost_per_kg: number | null;
grading_cost_per_kg: number | null;
bagging_cost_per_kg: number | null;
cracking_cost_per_kg: number | null;
bag_cost_per_unit: number | null;
freight_cost_per_unit: number | null;
finished_product_delivered_cost: number | null;
distributor_price: number | null;
wholesale_price: number | null;
warnings: string[];
created_at: string;
updated_at: string;
};
export type ProductCostingItemUpdateInput = {
client_category?: string;
item_id?: string | null;
product_name?: string;
mix_product_name?: string;
unit_type?: string;
own_bag?: string | null;
unit_kg?: number | null;
items_per_pallet?: number | null;
bagging_process?: string | null;
manual_distributor_margin?: number | null;
manual_wholesale_margin?: number | null;
};
export type ProductCostingNamedInput = {
key: string;
label: string;
cost: number;
};
export type ProductCostingClientInput = {
client_category: string;
distributor_margin: number | null;
wholesale_margin: number | null;
};
export type ProductCostingInputs = {
base: {
grading_per_tonne: number;
grading_per_kg: number;
cracking_per_tonne: number;
cracking_per_kg: number;
};
processes: ProductCostingNamedInput[];
clients: ProductCostingClientInput[];
bags: ProductCostingNamedInput[];
freight: ProductCostingNamedInput[];
};
export type EditorProductRow = {
id: number;
tenant_id: string;
@@ -382,6 +449,39 @@ export type DashboardSummary = {
mix_cost_per_kg: number[];
product_finished_delivered: number[];
};
operations?: {
period_label: string;
total_kg: number;
total_bags: number;
entry_count: number;
estimated_wholesale_value: number;
priced_entry_count: number;
top_products: Array<{
product_name: string;
client_name: string | null;
kg: number;
bags: number;
entries: number;
}>;
client_totals: Array<{
client_name: string;
kg: number;
}>;
pricing_issues: {
missing_lookup: number;
missing_unit_kg: number;
missing_pallet_qty: number;
missing_price: number;
invalid_margin: number;
total: number;
};
produced_not_priced: Array<{
product_name: string;
kg: number;
status: string;
warnings: string[];
}>;
} | null;
};
export type LoginResponse = {
+10
View File
@@ -89,6 +89,10 @@ export function canOpenProducts(session: AppSession | null | undefined) {
return canAccessWorkspaceArea(session, 'products', ['view_products', 'edit_products']);
}
export function canOpenProductCosting(session: AppSession | null | undefined) {
return canOpenProducts(session);
}
export function canOpenEditor(session: AppSession | null | undefined) {
if (!session) {
return false;
@@ -140,6 +144,11 @@ export const routeAccessRules: RouteAccessRule[] = [
matches: (pathname) => hasPathPrefix(pathname, '/raw-materials')
},
{ path: '/mixes', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/mixes') },
{
path: '/product-costing',
roles: ['admin', 'full', 'client'],
matches: (pathname) => hasPathPrefix(pathname, '/product-costing')
},
{ path: '/products', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/products') },
{ path: '/editor', roles: ['admin'], matches: (pathname) => hasPathPrefix(pathname, '/editor') },
{ path: '/reporting', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/reporting') },
@@ -196,6 +205,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
if (pathname.startsWith('/mix-calculator')) return canOpenMixCalculator(session);
if (pathname.startsWith('/raw-materials')) return canOpenRawMaterials(session);
if (pathname.startsWith('/mixes')) return canOpenMixMaster(session);
if (pathname.startsWith('/product-costing')) return canOpenProductCosting(session);
if (pathname.startsWith('/products')) return canOpenProducts(session);
if (pathname.startsWith('/editor')) return canOpenEditor(session);
if (pathname.startsWith('/scenarios')) return canOpenScenarios(session);
+2 -16
View File
@@ -3,6 +3,8 @@
import '@fontsource/inter/latin-500.css';
import '@fontsource/inter/latin-600.css';
import '@fontsource/inter/latin-700.css';
import '$lib/styles/theme.css';
import '$lib/theme';
import { beforeNavigate, afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import AdminShell from '$lib/components/AdminShell.svelte';
@@ -51,19 +53,3 @@
<Toast />
<style>
:global(html, body) {
font-family: "Inter", "Segoe UI", sans-serif;
}
:global(button),
:global(input),
:global(select),
:global(textarea) {
font: inherit;
}
:global(h1, h2, h3, h4, h5, h6) {
font-family: "Inter", "Segoe UI", sans-serif;
}
</style>
+471 -17
View File
@@ -6,7 +6,18 @@
import type { DashboardSummary } from '$lib/types';
import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access';
import packageInfo from '../../package.json';
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
import {
ArrowUpRight,
BadgeDollarSign,
Factory,
PackageCheck,
Scale,
Sun,
Sunrise,
Sunset,
TriangleAlert,
Moon
} from 'lucide-svelte';
import { tick } from 'svelte';
type Segment = {
@@ -115,6 +126,13 @@
return `$${value.toFixed(digits)}`;
}
function kg(value: number | null | undefined) {
if (value === null || value === undefined) {
return '0 kg';
}
return `${value.toLocaleString(undefined, { maximumFractionDigits: 0 })} kg`;
}
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No date';
@@ -314,6 +332,7 @@
const trendArea = $derived(areaPath(trendSeries));
const trendFocus = $derived(focusMarker(trendSeries));
const topProducts = $derived(summary?.products?.top_products ?? []);
const operations = $derived(summary?.operations ?? null);
const focusCards = $derived(buildFocusCards(summary));
const loading = $derived(summary === null);
const greeting = $derived(timeOfDay());
@@ -465,7 +484,7 @@
</div>
<div class="intro-actions">
{#if canOpenEditor($clientSession)}
<a class="primary-button" href="/editor">Open Editor</a>
<a class="primary-button" href="/editor">Open Mix Editor</a>
{/if}
</div>
</section>
@@ -610,6 +629,146 @@
</div>
</section>
<section class="operations-report">
<div class="card-toolbar operations-toolbar">
<div class="operations-heading">
<span class="operations-icon"><Factory size={24} strokeWidth={2.2} /></span>
<div>
<h3>Production And Pricing</h3>
<p>Throughput and Product Costing for {operations?.period_label ?? 'this month'}</p>
</div>
</div>
<a class="secondary-button compact operations-link" href="/product-costing">
Open Product Costing
<ArrowUpRight size={15} strokeWidth={2.3} />
</a>
</div>
<div class="operations-graphic" aria-hidden="true">
<div class="operations-graphic-labels">
<span>Throughput</span>
<span>Costing</span>
<span>Pricing</span>
</div>
<div class="operations-graphic-track">
<span class="operation-node production-node"></span>
<span class="operation-track"></span>
<span class="operation-bars">
<i></i>
<i></i>
<i></i>
</span>
<span class="operation-node pricing-node"></span>
</div>
</div>
<div class="operations-metrics">
<article class="produced">
<div class="metric-label">
<span>Produced</span>
<span class="metric-symbol"><PackageCheck size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="6rem" height="1.5rem" /></strong>
{:else}
<strong>{kg(operations?.total_kg)}</strong>
{/if}
<p>{operations?.entry_count ?? 0} throughput entries</p>
</article>
<article class="bags">
<div class="metric-label">
<span>Bags</span>
<span class="metric-symbol"><Scale size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="5rem" height="1.5rem" /></strong>
{:else}
<strong>{(operations?.total_bags ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</strong>
{/if}
<p>Logged as bag runs</p>
</article>
<article class="value">
<div class="metric-label">
<span>Wholesale Value</span>
<span class="metric-symbol"><BadgeDollarSign size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="7rem" height="1.5rem" /></strong>
{:else}
<strong>{currency(operations?.estimated_wholesale_value)}</strong>
{/if}
<p>{operations?.priced_entry_count ?? 0} priced entries</p>
</article>
<article class:warning={(operations?.pricing_issues?.total ?? 0) > 0}>
<div class="metric-label">
<span>Pricing Issues</span>
<span class="metric-symbol"><TriangleAlert size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="4rem" height="1.5rem" /></strong>
{:else}
<strong>{operations?.pricing_issues?.total ?? 0}</strong>
{/if}
<p>Products needing review</p>
</article>
</div>
<div class="operations-grid">
<article>
<div class="mini-heading">
<strong>Top Produced</strong>
<span>By kg</span>
</div>
<div class="report-list">
{#if loading}
{#each Array(4) as _}
<div><Skeleton width="10rem" /><Skeleton width="4rem" /></div>
{/each}
{:else if operations?.top_products?.length}
{#each operations.top_products as product}
<div>
<span>
<strong>{product.product_name}</strong>
<small>{product.client_name ?? 'No client'} · {product.entries} entries</small>
</span>
<em>{kg(product.kg)}</em>
</div>
{/each}
{:else}
<p>No throughput recorded this month.</p>
{/if}
</div>
</article>
<article>
<div class="mini-heading">
<strong>Produced But Not Priced</strong>
<span>Fix these first</span>
</div>
<div class="report-list">
{#if loading}
{#each Array(4) as _}
<div><Skeleton width="10rem" /><Skeleton width="4rem" /></div>
{/each}
{:else if operations?.produced_not_priced?.length}
{#each operations.produced_not_priced as product}
<div>
<span>
<strong>{product.product_name}</strong>
<small>{product.warnings[0] ?? product.status}</small>
</span>
<em>{kg(product.kg)}</em>
</div>
{/each}
{:else}
<p>All produced products have usable pricing.</p>
{/if}
</div>
</article>
</div>
</section>
<section class="analysis-grid">
<article class="panel-card chart-card">
<div class="card-toolbar">
@@ -1094,6 +1253,7 @@
.workspace-banner,
.focus-row,
.dashboard-grid,
.operations-report,
.analysis-grid,
.detail-grid {
margin-bottom: 1.25rem;
@@ -1177,15 +1337,15 @@
.primary-button {
border: none;
color: #fff;
color: var(--color-on-brand);
background: var(--color-brand);
box-shadow: none;
}
.secondary-button {
border: 1px solid var(--line-strong);
color: #304038;
background: #fff;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
background: var(--color-bg-surface);
}
.secondary-button.compact {
@@ -1206,6 +1366,276 @@
padding: 1.2rem;
}
.operations-report {
position: relative;
overflow: hidden;
padding: 1.2rem;
border: 1px solid var(--color-border);
border-radius: 1.4rem;
background: var(--color-bg-surface);
box-shadow: var(--shadow);
}
.operations-toolbar {
margin-bottom: 0.9rem;
}
.operations-heading {
display: flex;
align-items: center;
gap: 0.85rem;
min-width: 0;
}
.operations-heading h3 {
margin: 0 0 0.18rem;
color: var(--text);
}
.operations-heading p {
margin: 0;
color: var(--muted);
}
.operations-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.1rem;
height: 3.1rem;
flex-shrink: 0;
border: 1px solid rgba(21, 128, 61, 0.18);
border-radius: 1rem;
background: #f0f8f3;
color: #0f6f3d;
box-shadow: inset 0 -0.45rem 1rem rgba(21, 128, 61, 0.08);
}
.operations-graphic {
display: grid;
gap: 0.5rem;
margin-bottom: 0.95rem;
padding: 0.75rem 0.85rem;
border: 1px solid rgba(214, 228, 220, 0.86);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.62);
box-shadow: inset 0 -0.6rem 1.4rem rgba(21, 128, 61, 0.06);
}
.operations-graphic-labels,
.operations-graphic-track {
display: grid;
grid-template-columns: 2.25rem minmax(4rem, 1fr) 4.5rem 2.25rem;
align-items: center;
gap: 0.45rem;
}
.operations-graphic-labels {
grid-template-columns: repeat(3, minmax(0, 1fr));
color: #365243;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.operations-graphic-labels span:nth-child(2) {
text-align: center;
}
.operations-graphic-labels span:nth-child(3) {
text-align: right;
}
.operation-node {
display: block;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.8rem;
background: #0f6f3d;
box-shadow: inset 0 -0.35rem 0.65rem rgba(0, 0, 0, 0.12);
}
.pricing-node {
background: #e7ad3c;
}
.operation-track {
align-self: center;
height: 0.24rem;
border-radius: 999px;
background: linear-gradient(90deg, rgba(21, 128, 61, 0.22), rgba(59, 130, 196, 0.58), rgba(231, 173, 60, 0.55));
}
.operation-bars {
display: flex;
align-items: end;
justify-content: center;
gap: 0.28rem;
height: 2.4rem;
}
.operation-bars i {
display: block;
width: 0.7rem;
border-radius: 999px 999px 0.25rem 0.25rem;
background: #3b82c4;
}
.operation-bars i:nth-child(1) {
height: 1.2rem;
}
.operation-bars i:nth-child(2) {
height: 2rem;
background: #15803d;
}
.operation-bars i:nth-child(3) {
height: 1.55rem;
background: #e7ad3c;
}
.operations-link {
gap: 0.45rem;
white-space: nowrap;
}
.operations-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.operations-metrics article {
position: relative;
overflow: hidden;
padding: 0.95rem;
border: 1px solid rgba(214, 228, 220, 0.94);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.84);
box-shadow: 0 0.75rem 1.4rem rgba(43, 57, 47, 0.05);
}
.operations-metrics article::after {
content: '';
position: absolute;
right: 0.85rem;
bottom: 0;
left: 0.85rem;
height: 0.24rem;
border-radius: 999px 999px 0 0;
background: #15803d;
opacity: 0.65;
}
.operations-metrics article.bags::after {
background: #3b82c4;
}
.operations-metrics article.value::after {
background: #e7ad3c;
}
.operations-metrics article.warning {
border-color: rgba(231, 173, 60, 0.44);
background: #fff8e8;
}
.operations-metrics article.warning::after {
background: #b45309;
}
.metric-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.metric-symbol {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #0f6f3d;
}
.bags .metric-symbol {
color: #2f6f9f;
}
.value .metric-symbol {
color: #9a6718;
}
.warning .metric-symbol {
color: #9a3412;
}
.metric-label span,
.operations-metrics p,
.mini-heading span,
.report-list small {
color: var(--muted);
}
.operations-metrics strong {
display: block;
margin: 0.4rem 0 0.25rem;
font-size: 1.55rem;
line-height: 1;
}
.operations-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.operations-grid > article {
padding-top: 0.9rem;
border-top: 1px solid var(--line);
}
.mini-heading,
.report-list div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.mini-heading {
margin-bottom: 0.65rem;
}
.report-list {
display: grid;
gap: 0.55rem;
}
.report-list div {
padding: 0.65rem 0;
border-bottom: 1px solid var(--line);
}
.report-list div:last-child {
border-bottom: none;
}
.report-list strong,
.report-list small {
display: block;
}
.report-list em {
flex-shrink: 0;
font-style: normal;
font-weight: 700;
}
.focus-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1249,15 +1679,15 @@
}
.focus-card.positive {
background: linear-gradient(180deg, #f6fbf7 0%, #edf8f0 100%);
background: var(--color-success-tint);
}
.focus-card.warning {
background: linear-gradient(180deg, #fffaf3 0%, #fff3e3 100%);
background: var(--color-warning-tint);
}
.focus-card.neutral {
background: linear-gradient(180deg, #f7faf8 0%, #eff4f1 100%);
background: var(--panel-soft);
}
.focus-code {
@@ -1267,8 +1697,8 @@
align-items: center;
justify-content: center;
border-radius: 0.75rem;
color: #fff;
background: var(--green-deep);
color: var(--color-on-brand);
background: var(--color-brand);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.05em;
@@ -1369,8 +1799,9 @@
}
.toggle-pill .active {
color: #fff;
background: var(--green-deep);
color: var(--color-text-primary);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
}
.market-layout {
@@ -1410,7 +1841,11 @@
height: 8.5rem;
flex-shrink: 0;
border-radius: 1.4rem;
background: linear-gradient(180deg, #d7f0ff 0%, #fff0bf 45%, #89c762 46%, #3f8e3d 100%);
background: linear-gradient(
150deg,
var(--color-brand) 0%,
color-mix(in srgb, var(--color-brand) 70%, var(--color-text-primary)) 100%
);
overflow: hidden;
}
@@ -1421,7 +1856,12 @@
width: 2.9rem;
height: 2.9rem;
border-radius: 999px;
background: radial-gradient(circle at 35% 35%, #fff8cc 0%, #ffd865 58%, #f4ae1f 100%);
background: radial-gradient(
circle at 38% 38%,
color-mix(in srgb, var(--color-on-brand) 38%, transparent) 0%,
color-mix(in srgb, var(--color-on-brand) 12%, transparent) 62%,
transparent 72%
);
}
.field-stripe {
@@ -1430,7 +1870,7 @@
right: -10%;
height: 22%;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
background: color-mix(in srgb, var(--color-on-brand) 16%, transparent);
transform: rotate(-18deg);
}
@@ -1864,10 +2304,15 @@
@media (max-width: 1120px) {
.analysis-grid,
.detail-grid {
.detail-grid,
.operations-grid {
grid-template-columns: 1fr;
}
.operations-link {
justify-self: start;
}
.focus-grid {
min-width: 0;
}
@@ -1889,6 +2334,10 @@
.focus-grid {
grid-template-columns: 1fr;
}
.operations-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
@@ -1935,10 +2384,15 @@
}
.preview-facts,
.operations-metrics,
.signin-form {
grid-template-columns: 1fr;
}
.operations-graphic {
width: 100%;
}
.field-emblem {
width: 6.5rem;
height: 6.5rem;
+2 -1
View File
@@ -8,7 +8,8 @@ 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: [] }
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] },
operations: null
};
// Streaming load: the route shell paints immediately and the dashboard fills
+203 -135
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import type { EditorProductFormula, EditorProductIngredient, EditorProductRow, RawMaterial } from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
@@ -345,94 +346,99 @@
});
</script>
<section class="editor">
<div class="status-band">
<div class="editor-status">
<span>
<strong>Editor</strong>
<small>{visibleRows.length} products across {uniqueMixCount} mixes</small>
</span>
<AppSecondaryRailLayout>
{#snippet rail()}
<div class="filter-rail" aria-label="Mix Editor filters">
<p class="rail-label">Mix Editor</p>
<div class="rail-identity">
<div class="rail-avatar" aria-hidden="true">
<ListFilter size={16} strokeWidth={1.8} />
</div>
<div class="rail-identity-text">
<p class="identity-name">Filter products</p>
<p class="identity-role">{visibleRows.length} matching rows</p>
</div>
</div>
<div class="filter-rail-body">
<label class="filter-search">
<span>Search</span>
<div class="search-input">
<Search size={17} strokeWidth={2.2} />
<input bind:value={query} type="search" placeholder="Client, product, mix, item ID, unit" />
</div>
</label>
<div class="status-filter" role="group" aria-label="Status filter">
<span class="field-label">Status</span>
<div class="segmented-control">
<button type="button" class:active={visibilityFilter === 'visible'} aria-pressed={visibilityFilter === 'visible'} onclick={() => setVisibilityFilter('visible')}>Active</button>
<button type="button" class:active={visibilityFilter === 'hidden'} aria-pressed={visibilityFilter === 'hidden'} onclick={() => setVisibilityFilter('hidden')}>Inactive</button>
<button type="button" class:active={visibilityFilter === 'all'} aria-pressed={visibilityFilter === 'all'} onclick={() => setVisibilityFilter('all')}>All</button>
</div>
</div>
<label>
<span>Client</span>
<select bind:value={clientFilter}>
<option value="all">All clients</option>
{#each clientOptions as client}
<option value={client}>{client}</option>
{/each}
</select>
</label>
<label>
<span>Product</span>
<input bind:value={productFilter} type="search" placeholder="Product name" />
</label>
<label>
<span>Pack</span>
<select bind:value={packFilter}>
<option value="all">All packs</option>
{#each packOptions as option}
<option value={option}>{option}</option>
{/each}
</select>
</label>
{#if filtersActive}
<button type="button" class="clear-button rail-clear" onclick={clearFilters}>
<X size={16} strokeWidth={2.4} /> Clear filters
</button>
{/if}
</div>
</div>
{/snippet}
<dl class="facts">
<div class="fact">
<dt>Products</dt>
<dd>{visibleRows.length}</dd>
</div>
<div class="fact">
<dt>Mixes</dt>
<dd>{uniqueMixCount}</dd>
</div>
<div class="fact">
<dt>Unsaved</dt>
<dd>{dirtyCount}</dd>
</div>
</dl>
</div>
<section class="filters" aria-label="Editor filters">
<div class="filter-head">
<div>
<span class="section-label">
<ListFilter size={15} strokeWidth={2.2} />
Filters
<section class="editor">
<div class="status-band">
<div class="editor-status">
<span>
<strong>Mix Editor</strong>
<small>{visibleRows.length} products across {uniqueMixCount} mixes</small>
</span>
<strong>{visibleRows.length} matching products</strong>
</div>
{#if filtersActive}
<button type="button" class="clear-button" onclick={clearFilters}>
<X size={16} strokeWidth={2.4} /> Clear filters
</button>
{/if}
<dl class="facts">
<div class="fact">
<dt>Products</dt>
<dd>{visibleRows.length}</dd>
</div>
<div class="fact">
<dt>Mixes</dt>
<dd>{uniqueMixCount}</dd>
</div>
<div class="fact">
<dt>Unsaved</dt>
<dd>{dirtyCount}</dd>
</div>
</dl>
</div>
<div class="filter-grid">
<label class="filter-search">
<span>Search</span>
<div class="search-input">
<Search size={17} strokeWidth={2.2} />
<input bind:value={query} type="search" placeholder="Client, product, mix, item ID, unit" />
</div>
</label>
<div class="status-filter" role="group" aria-label="Status filter">
<span class="field-label">Status</span>
<div class="segmented-control">
<button type="button" class:active={visibilityFilter === 'visible'} aria-pressed={visibilityFilter === 'visible'} onclick={() => setVisibilityFilter('visible')}>Active</button>
<button type="button" class:active={visibilityFilter === 'hidden'} aria-pressed={visibilityFilter === 'hidden'} onclick={() => setVisibilityFilter('hidden')}>Inactive</button>
<button type="button" class:active={visibilityFilter === 'all'} aria-pressed={visibilityFilter === 'all'} onclick={() => setVisibilityFilter('all')}>All</button>
</div>
</div>
<label>
<span>Client</span>
<select bind:value={clientFilter}>
<option value="all">All clients</option>
{#each clientOptions as client}
<option value={client}>{client}</option>
{/each}
</select>
</label>
<label>
<span>Product</span>
<input bind:value={productFilter} type="search" placeholder="Product name" />
</label>
<label>
<span>Pack</span>
<select bind:value={packFilter}>
<option value="all">All packs</option>
{#each packOptions as option}
<option value={option}>{option}</option>
{/each}
</select>
</label>
</div>
</section>
<div class="pagination-bar" aria-label="Product table pagination">
<div class="pagination-bar" aria-label="Product table pagination">
<span>{pageStart}-{pageEnd} of {visibleRows.length}</span>
<label class="page-size">
<span>Rows</span>
@@ -452,9 +458,9 @@
<ChevronRight size={16} strokeWidth={2.4} />
</button>
</div>
</div>
</div>
<div class="log">
<div class="log">
<div class="log-head" aria-hidden="true">
<span>Client</span>
<span>ID</span>
@@ -576,15 +582,104 @@
<button type="button" class="clear-button" onclick={clearFilters}>Clear filters</button>
</div>
{/each}
</div>
</section>
</div>
</section>
</AppSecondaryRailLayout>
<style>
.editor {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0 2rem;
gap: 0.9rem;
min-height: 100%;
padding: 1rem 1.15rem 2rem;
background: #e8eee9;
}
:global(.secondary-rail-layout) {
margin-bottom: 1.25rem;
}
:global(.secondary-rail-layout-panel),
:global(.secondary-rail-layout-content) {
background: #e8eee9;
}
.filter-rail {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
height: 100%;
min-height: calc(100vh - 8.5rem);
background: color-mix(in srgb, var(--panel-soft) 46%, #dfe7e1);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.rail-label {
margin: 0;
padding: 1rem 1rem 0.15rem;
color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.rail-identity {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 1rem 1.15rem;
border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
}
.rail-avatar {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.15rem;
height: 2.15rem;
border: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--panel) 80%, #edf2ee);
color: #6b786f;
}
.rail-identity-text {
min-width: 0;
}
.identity-name,
.identity-role {
margin: 0;
}
.identity-name {
color: #526059;
font-size: 0.8rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.identity-role {
color: #8a9790;
font-size: 0.72rem;
}
.filter-rail-body {
display: grid;
gap: 0.85rem;
padding: 0.8rem 0.8rem 1rem;
}
.rail-clear {
width: 100%;
}
.status-band {
@@ -706,39 +801,6 @@
padding-inline: 0.2rem;
}
.filters {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 0.9rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 0.9rem;
}
.filter-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.8rem;
border-bottom: 1px solid var(--color-divider);
}
.filter-head div {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.filter-head strong {
color: var(--color-text-primary);
font-size: 1rem;
font-weight: 700;
}
.section-label,
.field-label {
display: inline-flex;
align-items: center;
@@ -748,13 +810,6 @@
font-weight: 700;
}
.filter-grid {
display: grid;
grid-template-columns: minmax(18rem, 1.4fr) minmax(15rem, 0.95fr) minmax(13rem, 0.8fr) minmax(13rem, 0.9fr) minmax(9rem, 0.5fr);
gap: 0.65rem;
align-items: end;
}
.pagination-bar {
display: flex;
align-items: center;
@@ -1130,8 +1185,7 @@
justify-content: flex-start;
}
.ingredient-grid,
.filter-grid {
.ingredient-grid {
grid-template-columns: 1fr;
}
@@ -1140,14 +1194,28 @@
}
}
@media (max-width: 760px) {
.filter-head {
align-items: stretch;
flex-direction: column;
@media (max-width: 980px) {
.filter-rail {
position: static;
min-height: auto;
height: auto;
border-right: none;
overflow: visible;
}
.filter-head .clear-button {
width: 100%;
.filter-rail-body {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: end;
}
.filter-search {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.filter-rail-body {
grid-template-columns: 1fr;
}
.facts {
+11 -6
View File
@@ -11,6 +11,7 @@ const apiMocks = vi.hoisted(() => ({
productCosts: vi.fn(),
scenarios: vi.fn(),
dataQuality: vi.fn(),
dashboardSummary: vi.fn(),
clientAccess: vi.fn(),
clientAccessExport: vi.fn()
}));
@@ -61,6 +62,13 @@ describe('route loaders use the SvelteKit fetch argument', () => {
apiMocks.productCosts.mockResolvedValue([{ id: 4 }]);
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
apiMocks.dataQuality.mockResolvedValue([{ id: 6 }]);
apiMocks.dashboardSummary.mockResolvedValue({
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] },
operations: null
});
apiMocks.clientAccess.mockResolvedValue([{ id: 7 }]);
apiMocks.clientAccessExport.mockResolvedValue({
generated_at: '',
@@ -74,13 +82,10 @@ describe('route loaders use the SvelteKit fetch argument', () => {
});
it('passes fetch through the home page loader', async () => {
await homeLoad({ fetch: fetcher } as never);
const result = homeLoad({ fetch: fetcher } as never);
await result.summary;
expect(apiMocks.rawMaterials).toHaveBeenCalledWith(fetcher);
expect(apiMocks.mixes).toHaveBeenCalledWith(fetcher);
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
expect(apiMocks.scenarios).toHaveBeenCalledWith(fetcher);
expect(apiMocks.dataQuality).toHaveBeenCalledWith(fetcher);
expect(apiMocks.dashboardSummary).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the raw materials loader', async () => {
@@ -63,7 +63,7 @@
<thead>
<tr>
<th>Session</th>
<th>Client / Product</th>
<th>Client / Mix</th>
<th>Batch</th>
<th>Bags</th>
<th>Prepared by</th>
@@ -78,7 +78,7 @@
<strong>{session.session_number}</strong>
<span>{session.mix_name}</span>
</td>
<td data-label="Client / Product">
<td data-label="Client / Mix">
<strong>{session.product_name}</strong>
<span>{session.client_name}</span>
</td>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canOpenProductCosting, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { items: [], inputs: null };
}
const session = getStoredClientSession();
if (!canOpenProductCosting(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const canRead = hasModuleAccess(session, 'products') || session?.role === 'internal';
if (!canRead) {
return { items: [], inputs: null };
}
const [items, inputs] = await Promise.all([
api.productCostingItems(fetch),
api.productCostingInputs(fetch)
]);
return { items, inputs };
} catch {
return { items: [], inputs: null };
}
}
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@
} from '$lib/types';
import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte';
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
import { toNum } from '$lib/format';
let { data } = $props<{ data: { products: ThroughputProduct[] } }>();
const products = $derived(data.products ?? []);
@@ -53,13 +54,6 @@
}
});
function toNum(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
function resetExceptDateAndStaff() {
productId = '';
bagSize = '';