v0.1.12
This commit is contained in:
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.11b",
|
||||
"version": "0.1.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +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 = '';
|
||||
|
||||
Reference in New Issue
Block a user