Mix calculator

This commit is contained in:
2026-04-29 23:05:27 +12:00
parent 3f3b1d0f25
commit 5cb95266d8
28 changed files with 2943 additions and 46 deletions
+28
View File
@@ -4,6 +4,8 @@ import {
mockClientAccess,
mockClientAccessExport,
mockCosts,
mockMixCalculatorOptions,
mockMixCalculatorSessions,
mockMixes,
mockProducts,
mockRawMaterials,
@@ -16,6 +18,11 @@ import type {
ClientUserModulePermission,
ClientUserUpdateInput,
LoginResponse,
MixCalculatorCreateInput,
MixCalculatorOptions,
MixCalculatorPreview,
MixCalculatorSession,
MixCalculatorUpdateInput,
Mix,
MixCreateInput,
MixIngredientUpdateInput,
@@ -128,6 +135,27 @@ export const api = {
rawMaterials: (fetcher?: ApiFetch) => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, 'client', fetcher),
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorOptions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
mixCalculatorSessions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
request<MixCalculatorPreview>('/api/mix-calculator/preview', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
createMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
request<MixCalculatorSession>('/api/mix-calculator', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateMixCalculatorSession: (sessionId: number, payload: MixCalculatorUpdateInput) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
products: (fetcher?: ApiFetch) => fetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
productCosts: (fetcher?: ApiFetch) =>
fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
+63 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { api } from '$lib/api';
import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
@@ -23,6 +24,7 @@
};
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' };
const mixCalculatorItem: NavItem = { href: '/mix-calculator', label: 'Mix Calculator', shortLabel: 'MC', moduleKey: 'mix_calculator' };
const workingDocumentItems: NavItem[] = [
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' },
@@ -30,7 +32,7 @@
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', moduleKey: 'scenarios' }
];
const accessControlItem: NavItem = { href: '/client-access', label: 'Client Access', shortLabel: 'AC', moduleKey: 'client_access' };
const navigation = [dashboardItem, ...workingDocumentItems, accessControlItem];
const navigation = [dashboardItem, mixCalculatorItem, ...workingDocumentItems, accessControlItem];
const footerLinks = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
@@ -62,6 +64,18 @@
description: 'Start a new costing worksheet for Hunter Premium Produce.',
keywords: 'new mix create worksheet hunter premium produce formula'
},
{
href: '/mix-calculator',
label: 'Open Mix Calculator',
description: 'Review saved production sessions and batch calculations.',
keywords: 'mix calculator production sessions batch bags client product'
},
{
href: '/mix-calculator/new',
label: 'Create Mix Calculation',
description: 'Run a new client-specific mix calculation session.',
keywords: 'new mix calculator session client batch size product bags print'
},
{
href: '/products',
label: 'Open Products',
@@ -105,6 +119,11 @@
? workingDocumentItems
: workingDocumentItems.filter((item) => !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey))
);
const visibleMixCalculatorItem = $derived(
!$clientSession || !mixCalculatorItem.moduleKey || hasModuleAccess($clientSession, mixCalculatorItem.moduleKey)
? mixCalculatorItem
: null
);
const visibleFooterLinks = $derived([
...footerLinks,
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
@@ -112,7 +131,11 @@
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel }])
]);
const primaryBottomNavigation = $derived(
[...(visibleDashboardItem ? [visibleDashboardItem] : []), ...visibleWorkingDocumentItems.slice(0, 3)]
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...visibleWorkingDocumentItems.slice(0, 2)
]
);
function matchesRoute(href: string, pathname: string) {
@@ -124,11 +147,17 @@
}
function pageDescription(pathname: string) {
if (pathname.startsWith('/mix-calculator/')) {
return 'Review a saved mix calculation session and prepare a printable output';
}
const descriptions: Record<string, string> = {
'/': 'Hunter Premium Produce client workspace',
'/raw-materials': 'Review source input costs and downstream exposure',
'/mixes': 'Browse saved mix worksheets and costing outputs',
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce',
'/mix-calculator': 'Create and review client-specific mix calculation sessions',
'/mix-calculator/new': 'Create a new client-specific mix calculation session',
'/products': 'Track delivered product pricing and margin views',
'/settings': 'Review your workspace profile and application settings',
'/scenarios': 'Compare alternate pricing and production assumptions',
@@ -210,11 +239,19 @@
restoredToken = token;
isRestoringSession = true;
invalidateAll().finally(() => {
if (restoredToken === token) {
api.clientSession()
.then((session) => {
restoredToken = session.token;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredToken = null;
clientSession.clear();
})
.finally(() => {
isRestoringSession = false;
}
});
});
});
$effect(() => {
@@ -319,6 +356,13 @@
<span>{visibleDashboardItem.label}</span>
</a>
{/if}
{#if visibleMixCalculatorItem}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href}>
<span class="nav-icon">{visibleMixCalculatorItem.shortLabel}</span>
<span>{visibleMixCalculatorItem.label}</span>
</a>
{/if}
</nav>
<div class="nav-group" aria-label="Working documents" hidden={!visibleWorkingDocumentItems.length}>
@@ -396,6 +440,8 @@
<div class="menu-panel">
<a href="/mixes">Open mix costing</a>
<a href="/mixes/new">Create mix worksheet</a>
<a href="/mix-calculator">Open mix calculator</a>
<a href="/mix-calculator/new">Create mix session</a>
<a href="/products">Review delivered pricing</a>
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
</div>
@@ -568,6 +614,13 @@
</a>
{/if}
{#if visibleMixCalculatorItem}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">{visibleMixCalculatorItem.shortLabel}</span>
<span>{visibleMixCalculatorItem.label}</span>
</a>
{/if}
<div class="drawer-group" hidden={!visibleWorkingDocumentItems.length}>
<button
aria-controls="drawer-working-documents-nav"
@@ -597,6 +650,10 @@
<span class="nav-icon">NW</span>
<span>Create mix worksheet</span>
</a>
<a href="/mix-calculator/new" onclick={() => (navOpen = false)}>
<span class="nav-icon">MC</span>
<span>Create mix session</span>
</a>
<button type="button" onclick={openSettings}>
<span class="nav-icon muted">ST</span>
<span>Change settings</span>
@@ -0,0 +1,321 @@
<script lang="ts">
import type { MixCalculatorSession } from '$lib/types';
let { session }: { session: MixCalculatorSession } = $props();
function formatDate(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium',
timeStyle: undefined
}).format(new Date(value));
}
function formatTimestamp(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function formatNumber(value: number, digits = 2) {
return value.toFixed(digits);
}
const printableTitle = $derived(
`MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_')
);
</script>
<svelte:head>
<title>{printableTitle}</title>
</svelte:head>
<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={() => window.print()}>Print / Save PDF</button>
</div>
<article class="sheet">
<header class="sheet-header">
<div>
<p class="eyebrow">Mix Calculator</p>
<h1>{session.session_number}</h1>
<p>Generated from the saved session snapshot without re-reading live product or recipe data.</p>
</div>
<div class="sheet-meta">
<div>
<span>Generated</span>
<strong>{formatTimestamp(new Date().toISOString())}</strong>
</div>
<div>
<span>Status</span>
<strong>{session.status}</strong>
</div>
</div>
</header>
<section class="summary-grid">
<div>
<span>Date</span>
<strong>{formatDate(session.mix_date)}</strong>
</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>Batch size</span>
<strong>{formatNumber(session.batch_size_kg, 2)}kg</strong>
</div>
<div>
<span>Total bags</span>
<strong>{formatNumber(session.total_bags, 2)}</strong>
</div>
<div>
<span>Total kilograms</span>
<strong>{formatNumber(session.total_kg, 2)}kg</strong>
</div>
<div>
<span>Prepared by</span>
<strong>{session.prepared_by_name}</strong>
</div>
</section>
{#if session.notes}
<section class="notes-card">
<h2>Session notes</h2>
<p>{session.notes}</p>
</section>
{/if}
{#if session.warnings.length}
<section class="warning-card">
<h2>Warnings</h2>
{#each session.warnings as warning}
<p>{warning}</p>
{/each}
</section>
{/if}
<section class="table-card">
<div class="table-header">
<h2>Required raw materials</h2>
<span>{session.product_unit_of_measure} · {formatNumber(session.product_unit_size_kg, 2)}kg per unit</span>
</div>
<table>
<thead>
<tr>
<th>Raw material</th>
<th>Mix %</th>
<th>Required kg</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{#each session.lines as line}
<tr>
<td>{line.raw_material_name}</td>
<td>{formatNumber(line.mix_percentage, 2)}%</td>
<td>{formatNumber(line.required_kg, 2)}kg</td>
<td>{line.unit}</td>
</tr>
{/each}
</tbody>
</table>
</section>
</article>
</section>
<style>
h1,
h2,
p {
margin: 0;
}
.print-page {
display: grid;
gap: 1rem;
}
.print-toolbar {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.78rem 0.96rem;
border-radius: 0.9rem;
border: 1px solid var(--line-strong);
font-weight: 600;
cursor: pointer;
}
.primary-button {
border: none;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
color: #fff;
}
.secondary-button {
background: #fff;
color: #304038;
}
.sheet {
width: min(960px, 100%);
margin: 0 auto;
padding: 2rem;
border: 1px solid var(--line);
border-radius: 1.5rem;
background: #fff;
box-shadow: var(--shadow);
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sheet-header {
display: flex;
justify-content: space-between;
gap: 1.5rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--line);
}
.sheet-header h1 {
margin: 0.3rem 0 0.45rem;
font-size: clamp(2rem, 4vw, 2.6rem);
}
.sheet-header p:last-child,
.sheet-meta span,
.summary-grid span,
.table-header span {
color: var(--muted);
}
.sheet-meta {
min-width: 14rem;
display: grid;
gap: 0.9rem;
}
.sheet-meta div,
.summary-grid div {
display: grid;
gap: 0.16rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
padding: 1.35rem 0;
}
.notes-card,
.warning-card,
.table-card {
margin-top: 1rem;
}
.notes-card,
.warning-card {
padding: 1rem;
border-radius: 1rem;
}
.notes-card {
background: var(--panel-soft);
}
.warning-card {
background: #fff6e6;
color: #8b5b1e;
}
.warning-card h2,
.notes-card h2,
.table-header h2 {
margin-bottom: 0.45rem;
font-size: 1rem;
}
.table-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.88rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--line);
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@media (max-width: 900px) {
.sheet-header,
.table-header {
flex-direction: column;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media print {
:global(body) {
background: #fff;
}
.print-toolbar {
display: none;
}
.sheet {
width: 100%;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
box-shadow: none;
}
}
</style>
@@ -0,0 +1,763 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { clientSession, hasModuleAccess } from '$lib/session';
import type {
MixCalculatorCreateInput,
MixCalculatorOptions,
MixCalculatorPreview,
MixCalculatorSession
} from '$lib/types';
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
const todayIso = new Date().toISOString().slice(0, 10);
function initialClientNameValue() {
return initialSession?.client_name ?? options.clients[0] ?? '';
}
function initialProductIdValue() {
return initialSession?.product_id ?? 0;
}
function initialMixDateValue() {
return initialSession?.mix_date ?? todayIso;
}
function initialBatchSizeValue() {
return initialSession ? `${initialSession.batch_size_kg}` : '';
}
function initialPreparedByNameValue() {
return initialSession?.prepared_by_name ?? '';
}
function initialNotesValue() {
return initialSession?.notes ?? '';
}
function initialPreviewValue() {
return initialSession;
}
let clientName = $state(initialClientNameValue());
let productId = $state(initialProductIdValue());
let mixDate = $state(initialMixDateValue());
let batchSizeKg = $state(initialBatchSizeValue());
let preparedByName = $state(initialPreparedByNameValue());
let notes = $state(initialNotesValue());
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
let formError = $state('');
let formSuccess = $state('');
let previewLoading = $state(false);
let saveLoading = $state(false);
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
const isExistingSession = $derived(initialSession !== null);
const availableClients = $derived(
Array.from(new Set([...(options.clients ?? []), ...(initialSession ? [initialSession.client_name] : [])]))
);
const availableProducts = $derived(
initialSession && !options.products.some((product) => product.product_id === initialSession.product_id)
? [
...options.products,
{
product_id: initialSession.product_id,
client_name: initialSession.client_name,
product_name: initialSession.product_name,
mix_id: initialSession.mix_id,
mix_name: initialSession.mix_name,
unit_of_measure: initialSession.product_unit_of_measure,
unit_size_kg: initialSession.product_unit_size_kg,
mix_total_kg: initialSession.total_kg
}
]
: options.products
);
const filteredProducts = $derived(availableProducts.filter((product) => product.client_name === clientName));
const selectedProduct = $derived(filteredProducts.find((product) => product.product_id === productId) ?? null);
$effect(() => {
if (!clientName && availableClients.length) {
clientName = availableClients[0];
}
});
$effect(() => {
if (filteredProducts.length && !filteredProducts.some((product) => product.product_id === productId)) {
productId = filteredProducts[0].product_id;
return;
}
if (!filteredProducts.length) {
productId = 0;
}
});
$effect(() => {
if (!preparedByName && $clientSession?.name) {
preparedByName = $clientSession.name;
}
});
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);
}
function buildPayload(): MixCalculatorCreateInput | null {
formError = '';
formSuccess = '';
const numericBatchSize = Number(batchSizeKg);
if (!mixDate) {
formError = 'Select a mix date.';
return null;
}
if (!clientName) {
formError = 'Select a client.';
return null;
}
if (!productId) {
formError = 'Select a product.';
return null;
}
if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) {
formError = 'Enter a batch size greater than zero.';
return null;
}
if (!preparedByName.trim()) {
formError = 'Enter the prepared by name.';
return null;
}
return {
mix_date: mixDate,
client_name: clientName,
product_id: productId,
batch_size_kg: numericBatchSize,
prepared_by_name: preparedByName.trim(),
status: initialSession?.status ?? 'saved',
notes: notes.trim() || null
};
}
async function calculatePreview() {
const payload = buildPayload();
if (!payload) {
return;
}
previewLoading = true;
try {
preview = await api.previewMixCalculatorSession(payload);
formSuccess = 'Calculation refreshed from the saved product mix.';
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to calculate the mix session.';
} finally {
previewLoading = false;
}
}
async function saveSession(mode: 'update' | 'create') {
const payload = buildPayload();
if (!payload) {
return;
}
saveLoading = true;
try {
const saved =
mode === 'update' && initialSession
? await api.updateMixCalculatorSession(initialSession.id, payload)
: await api.createMixCalculatorSession(payload);
await goto(`/mix-calculator/${saved.id}`);
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to save the mix calculator session.';
saveLoading = false;
}
}
</script>
{#if !canEdit && !initialSession}
<section class="locked-card">
<p class="eyebrow">Mix Calculator</p>
<h2>Edit access is required to create a new session.</h2>
<p>View-only users can open saved sessions from history, but cannot create or update production calculations.</p>
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
</section>
{:else}
<section class="page-intro">
<div>
<p class="eyebrow">Mix Calculator</p>
<h2>{isExistingSession ? 'Edit saved mix session' : 'New mix calculation session'}</h2>
<p>Scale a saved product mix by batch size, review required raw materials, then save the session for history and printing.</p>
</div>
<div class="header-actions">
<a class="secondary-button" href="/mix-calculator">Session history</a>
{#if initialSession}
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
{/if}
</div>
</section>
<section class="workspace-grid">
<article class="form-card">
<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>
</div>
{#if selectedProduct}
<div class="product-pill">
<strong>{selectedProduct.unit_size_kg}kg</strong>
<span>{selectedProduct.unit_of_measure}</span>
</div>
{/if}
</div>
{#if formError}
<p class="message error">{formError}</p>
{/if}
{#if formSuccess}
<p class="message success">{formSuccess}</p>
{/if}
<div class="field-grid">
<label>
<span>Mix date</span>
<input bind:value={mixDate} disabled={!canEdit} type="date" />
</label>
<label>
<span>Client</span>
<select bind:value={clientName} disabled={!canEdit}>
<option value="">Select a client</option>
{#each availableClients as client}
<option value={client}>{client}</option>
{/each}
</select>
</label>
<label class="full-width">
<span>Product</span>
<select bind:value={productId} disabled={!canEdit || !filteredProducts.length}>
<option value={0}>Select a product</option>
{#each filteredProducts as product}
<option value={product.product_id}>
{product.product_name} · {product.mix_name} · {product.unit_of_measure}
</option>
{/each}
</select>
</label>
<label>
<span>Batch size (kg)</span>
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="560" type="number" />
</label>
<label>
<span>Prepared by</span>
<input bind:value={preparedByName} disabled={!canEdit} placeholder="Staff name" type="text" />
</label>
<label class="full-width">
<span>Notes</span>
<textarea bind:value={notes} disabled={!canEdit} placeholder="Optional production notes or shift context" rows="4"></textarea>
</label>
</div>
{#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>
</div>
{/if}
{#if canEdit}
<div class="action-row">
<button class="primary-button" disabled={previewLoading || saveLoading} type="button" onclick={calculatePreview}>
{previewLoading ? 'Calculating...' : 'Calculate mix'}
</button>
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create')}>
{saveLoading ? 'Saving...' : isExistingSession ? 'Save changes' : 'Save session'}
</button>
{#if initialSession}
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession('create')}>
Save as new
</button>
{/if}
</div>
{/if}
</article>
<article class="result-card">
<div class="section-header">
<div>
<h3>Calculated Output</h3>
<p>{preview ? 'Snapshot of the scaled raw material requirements.' : 'Run the calculation to preview the session output.'}</p>
</div>
{#if initialSession}
<div class="session-chip">
<span>Session</span>
<strong>{initialSession.session_number}</strong>
</div>
{/if}
</div>
{#if preview}
<div class="metric-row">
<article class="metric-card">
<span>Total kg</span>
<strong>{formatNumber(preview.total_kg, 2)}</strong>
<p>Scaled batch size</p>
</article>
<article class="metric-card">
<span>Total bags</span>
<strong>{formatNumber(preview.total_bags, 2)}</strong>
<p>{preview.product_unit_of_measure}</p>
</article>
<article class="metric-card">
<span>Prepared by</span>
<strong>{preview.prepared_by_name}</strong>
<p>{formatDate(preview.mix_date)}</p>
</article>
</div>
{#if preview.warnings.length}
<div class="warning-stack">
{#each preview.warnings as warning}
<p>{warning}</p>
{/each}
</div>
{/if}
<div class="summary-grid">
<div>
<span>Client</span>
<strong>{preview.client_name}</strong>
</div>
<div>
<span>Product</span>
<strong>{preview.product_name}</strong>
</div>
<div>
<span>Mix source</span>
<strong>{preview.mix_name}</strong>
</div>
<div>
<span>Unit size</span>
<strong>{formatNumber(preview.product_unit_size_kg, 2)}kg</strong>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Raw material</th>
<th>Mix %</th>
<th>Required kg</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{#each preview.lines as line}
<tr>
<td data-label="Raw material">
<strong>{line.raw_material_name}</strong>
</td>
<td data-label="Mix %">{formatNumber(line.mix_percentage, 2)}%</td>
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
<td data-label="Unit">{line.unit}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="empty-state">
<strong>No calculation yet</strong>
<span>Choose a client, product, date, and batch size, then run the calculator.</span>
</div>
{/if}
</article>
</section>
{/if}
<style>
h2,
h3,
p {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.workspace-grid {
margin-bottom: 1.2rem;
}
.page-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.page-intro h2 {
margin: 0.3rem 0 0.45rem;
max-width: 16ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.section-header p,
.metric-card p,
.summary-grid span,
.calculation-note span,
.empty-state span {
color: var(--muted);
}
.header-actions,
.action-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.workspace-grid {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
gap: 1rem;
}
.form-card,
.result-card,
.metric-card,
.locked-card {
border: 1px solid var(--line);
border-radius: 1.3rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.form-card,
.result-card,
.locked-card {
padding: 1.2rem;
}
.locked-card {
max-width: 42rem;
}
.locked-card h2 {
margin: 0.35rem 0 0.45rem;
font-size: clamp(1.7rem, 3vw, 2.1rem);
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.section-header h3 {
font-size: 1.15rem;
font-weight: 700;
}
.product-pill,
.session-chip {
display: grid;
gap: 0.14rem;
padding: 0.72rem 0.82rem;
border: 1px solid var(--line);
border-radius: 0.92rem;
background: var(--panel-soft);
}
.product-pill span,
.session-chip span {
color: var(--muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.field-grid label {
display: grid;
gap: 0.38rem;
}
.field-grid label span {
font-size: 0.88rem;
font-weight: 600;
}
.full-width {
grid-column: 1 / -1;
}
input,
select,
textarea {
width: 100%;
padding: 0.78rem 0.82rem;
border: 1px solid var(--line-strong);
border-radius: 0.88rem;
background: #fff;
color: var(--text);
}
textarea {
resize: vertical;
}
.calculation-note,
.warning-stack,
.empty-state {
margin-top: 1rem;
padding: 0.92rem;
border-radius: 1rem;
}
.calculation-note {
display: grid;
gap: 0.2rem;
background: var(--panel-soft);
}
.warning-stack {
display: grid;
gap: 0.45rem;
background: #fff6e6;
color: #8b5b1e;
}
.message {
margin-bottom: 0.85rem;
padding: 0.75rem 0.85rem;
border-radius: 0.88rem;
font-size: 0.9rem;
}
.message.error {
background: #fff1f0;
color: #b2463f;
}
.message.success {
background: var(--green-soft);
color: var(--green-deep);
}
.action-row {
margin-top: 1rem;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.78rem 0.96rem;
border-radius: 0.9rem;
border: 1px solid var(--line-strong);
font-weight: 600;
cursor: pointer;
}
.primary-button {
border: none;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
color: #fff;
}
.secondary-button {
background: #fff;
color: #304038;
}
button:disabled {
cursor: wait;
opacity: 0.7;
}
.metric-row,
.summary-grid {
display: grid;
gap: 0.85rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 1rem;
}
.metric-card {
padding: 1rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.84rem;
}
.metric-card strong {
display: block;
margin: 0.45rem 0 0.18rem;
font-size: 1.45rem;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 1rem;
}
.summary-grid div {
padding: 0.88rem 0.92rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.summary-grid span {
display: block;
margin-bottom: 0.2rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
min-width: 30rem;
border-collapse: collapse;
}
th,
td {
padding: 0.9rem 0.85rem;
text-align: left;
border-bottom: 1px solid var(--line);
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.empty-state {
display: grid;
gap: 0.2rem;
place-items: start;
background: var(--panel-soft);
}
@media (max-width: 980px) {
.workspace-grid {
grid-template-columns: 1fr;
}
.metric-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.page-intro,
.section-header {
flex-direction: column;
}
.field-grid,
.summary-grid {
grid-template-columns: 1fr;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.75rem;
}
tbody tr {
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
overflow: hidden;
}
tbody td {
border-bottom: 1px solid var(--line);
}
tbody td:last-child {
border-bottom: none;
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.24rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
}
</style>
+101 -9
View File
@@ -1,6 +1,8 @@
import type {
ClientAccessAccount,
ClientAccessPowerBiExport,
MixCalculatorOptions,
MixCalculatorSession,
Mix,
Product,
ProductCostBreakdown,
@@ -13,6 +15,7 @@ const MODULE_PERMISSIONS = {
dashboard: 'edit',
raw_materials: 'edit',
mix_master: 'edit',
mix_calculator: 'manage',
products: 'edit',
scenarios: 'edit',
powerbi_export: 'edit',
@@ -22,6 +25,7 @@ const MODULE_PERMISSIONS = {
dashboard: 'edit',
raw_materials: 'edit',
mix_master: 'edit',
mix_calculator: 'edit',
products: 'edit',
scenarios: 'edit',
powerbi_export: 'none',
@@ -31,6 +35,7 @@ const MODULE_PERMISSIONS = {
dashboard: 'view',
raw_materials: 'none',
mix_master: 'none',
mix_calculator: 'view',
products: 'view',
scenarios: 'none',
powerbi_export: 'view',
@@ -42,6 +47,7 @@ const MODULE_DETAILS = [
['dashboard', 'Dashboard', 'workspace', 'Top-level operational dashboard'],
['raw_materials', 'Raw Materials', 'costing', 'Maintain live material costs and versions'],
['mix_master', 'Mix Master', 'costing', 'Create and maintain mix worksheets'],
['mix_calculator', 'Mix Calculator', 'production', 'Create and review client-specific mix calculation sessions'],
['products', 'Products', 'pricing', 'Review finished product pricing'],
['scenarios', 'Scenarios', 'planning', 'Run scenario overrides and comparisons'],
['powerbi_export', 'Power BI Export', 'reporting', 'Expose client access data to BI consumers'],
@@ -122,6 +128,70 @@ export const mockProducts: Product[] = [
}
];
export const mockMixCalculatorOptions: MixCalculatorOptions = {
clients: ['Hunter Premium Produce'],
products: [
{
product_id: 1,
client_name: 'Hunter Premium Produce',
product_name: 'Hunter Orchard Blend 20kg',
mix_id: 1,
mix_name: 'Hunter Orchard Blend',
unit_of_measure: '20kg bag',
unit_size_kg: 20,
mix_total_kg: 280
}
]
};
export const mockMixCalculatorSessions: MixCalculatorSession[] = [
{
id: 1,
tenant_id: 'hunter-premium-produce',
session_number: 'HPP-20260429-0001',
client_name: 'Hunter Premium Produce',
product_id: 1,
product_name: 'Hunter Orchard Blend 20kg',
mix_id: 1,
mix_name: 'Hunter Orchard Blend',
mix_date: '2026-04-29',
batch_size_kg: 560,
total_bags: 28,
total_kg: 560,
product_unit_of_measure: '20kg bag',
product_unit_size_kg: 20,
prepared_by_user_id: 1,
prepared_by_name: 'Amelia Hart',
created_by: 'operator@example.com',
status: 'saved',
notes: 'Morning production run',
created_at: '2026-04-29T08:10:00',
updated_at: '2026-04-29T08:12:00',
warnings: [],
is_owner: true,
lines: [
{
id: 1,
raw_material_id: 1,
raw_material_name: 'Maize',
required_kg: 360,
mix_percentage: 64.2857,
unit: 'tonne',
sort_order: 1
},
{
id: 2,
raw_material_id: 2,
raw_material_name: 'Barley',
required_kg: 200,
mix_percentage: 35.7143,
unit: 'tonne',
sort_order: 2
}
]
}
];
export const mockCosts: ProductCostBreakdown[] = [
{
product_id: 1,
@@ -157,8 +227,8 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00',
active_user_count: 1,
new_user_count: 1,
enabled_feature_count: 7,
total_feature_count: 7,
enabled_feature_count: 8,
total_feature_count: 8,
users: [
{
id: 1,
@@ -244,6 +314,17 @@ export const mockClientAccess: ClientAccessAccount[] = [
{
id: 4,
client_account_id: 1,
feature_key: 'mix_calculator',
feature_name: 'Mix Calculator',
feature_group: 'production',
description: 'Create and review client-specific mix calculation sessions',
enabled: true,
updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-20T09:00:00'
},
{
id: 5,
client_account_id: 1,
feature_key: 'products',
feature_name: 'Products',
feature_group: 'pricing',
@@ -253,7 +334,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00'
},
{
id: 5,
id: 6,
client_account_id: 1,
feature_key: 'scenarios',
feature_name: 'Scenarios',
@@ -264,7 +345,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00'
},
{
id: 6,
id: 7,
client_account_id: 1,
feature_key: 'powerbi_export',
feature_name: 'Power BI Export',
@@ -275,7 +356,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00'
},
{
id: 13,
id: 8,
client_account_id: 1,
feature_key: 'client_access',
feature_name: 'Client Access',
@@ -314,8 +395,8 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-21T10:00:00',
active_user_count: 1,
new_user_count: 0,
enabled_feature_count: 3,
total_feature_count: 7,
enabled_feature_count: 4,
total_feature_count: 8,
users: [
{
id: 3,
@@ -378,6 +459,17 @@ export const mockClientAccess: ClientAccessAccount[] = [
{
id: 10,
client_account_id: 2,
feature_key: 'mix_calculator',
feature_name: 'Mix Calculator',
feature_group: 'production',
description: 'Create and review client-specific mix calculation sessions',
enabled: true,
updated_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00'
},
{
id: 11,
client_account_id: 2,
feature_key: 'products',
feature_name: 'Products',
feature_group: 'pricing',
@@ -387,7 +479,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-21T10:00:00'
},
{
id: 11,
id: 12,
client_account_id: 2,
feature_key: 'scenarios',
feature_name: 'Scenarios',
@@ -398,7 +490,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-21T10:00:00'
},
{
id: 12,
id: 13,
client_account_id: 2,
feature_key: 'powerbi_export',
feature_name: 'Power BI Export',
+95
View File
@@ -76,6 +76,101 @@ export type MixIngredientUpdateInput = {
notes?: string | null;
};
export type MixCalculatorProductOption = {
product_id: number;
client_name: string;
product_name: string;
mix_id: number;
mix_name: string;
unit_of_measure: string;
unit_size_kg: number;
mix_total_kg: number;
};
export type MixCalculatorOptions = {
clients: string[];
products: MixCalculatorProductOption[];
};
export type MixCalculatorLine = {
id?: number | null;
raw_material_id?: number | null;
raw_material_name: string;
required_kg: number;
mix_percentage: number;
unit: string;
sort_order: number;
};
export type MixCalculatorPreview = {
client_name: string;
product_id: number;
product_name: string;
mix_id: number;
mix_name: string;
mix_date: string;
batch_size_kg: number;
total_bags: number;
total_kg: number;
product_unit_of_measure: string;
product_unit_size_kg: number;
prepared_by_name: string;
status: string;
notes?: string | null;
warnings: string[];
lines: MixCalculatorLine[];
};
export type MixCalculatorSessionSummary = {
id: number;
tenant_id: string;
session_number: string;
client_name: string;
product_id: number;
product_name: string;
mix_id: number;
mix_name: string;
mix_date: string;
batch_size_kg: number;
total_bags: number;
total_kg: number;
product_unit_of_measure: string;
product_unit_size_kg: number;
prepared_by_user_id?: number | null;
prepared_by_name: string;
created_by: string;
status: string;
notes?: string | null;
created_at: string;
updated_at: string;
warnings: string[];
is_owner: boolean;
};
export type MixCalculatorSession = MixCalculatorSessionSummary & {
lines: MixCalculatorLine[];
};
export type MixCalculatorCreateInput = {
mix_date: string;
client_name: string;
product_id: number;
batch_size_kg: number;
prepared_by_name: string;
status?: string;
notes?: string | null;
};
export type MixCalculatorUpdateInput = {
mix_date?: string;
client_name?: string;
product_id?: number;
batch_size_kg?: number;
prepared_by_name?: string;
status?: string;
notes?: string | null;
};
export type Product = {
id: number;
tenant_id?: string;
+4 -1
View File
@@ -6,9 +6,12 @@
let { children } = $props();
const isAdminRoute = $derived(page.url.pathname === '/admin' || page.url.pathname.startsWith('/admin/'));
const isPrintableRoute = $derived(page.url.pathname.startsWith('/mix-calculator/') && page.url.pathname.endsWith('/print'));
</script>
{#if isAdminRoute}
{#if isPrintableRoute}
{@render children()}
{:else if isAdminRoute}
<AdminShell>
{@render children()}
</AdminShell>
+35
View File
@@ -4,6 +4,9 @@ const apiMocks = vi.hoisted(() => ({
rawMaterials: vi.fn(),
mixes: vi.fn(),
mix: vi.fn(),
mixCalculatorOptions: vi.fn(),
mixCalculatorSessions: vi.fn(),
mixCalculatorSession: vi.fn(),
products: vi.fn(),
productCosts: vi.fn(),
scenarios: vi.fn(),
@@ -30,6 +33,10 @@ import { load as adminLoad } from './admin/+page';
import { load as mixesLoad } from './mixes/+page';
import { load as mixNewLoad } from './mixes/new/+page';
import { load as mixDetailLoad } from './mixes/[id]/+page';
import { load as mixCalculatorLoad } from './mix-calculator/+page';
import { load as mixCalculatorNewLoad } from './mix-calculator/new/+page';
import { load as mixCalculatorDetailLoad } from './mix-calculator/[id]/+page';
import { load as mixCalculatorPrintLoad } from './mix-calculator/[id]/print/+page';
import { load as productsLoad } from './products/+page';
import { load as rawMaterialsLoad } from './raw-materials/+page';
import { load as scenariosLoad } from './scenarios/+page';
@@ -47,6 +54,9 @@ describe('route loaders use the SvelteKit fetch argument', () => {
apiMocks.rawMaterials.mockResolvedValue([{ id: 1 }]);
apiMocks.mixes.mockResolvedValue([{ id: 2 }]);
apiMocks.mix.mockResolvedValue({ id: 42 });
apiMocks.mixCalculatorOptions.mockResolvedValue({ clients: ['Hunter Premium Produce'], products: [{ product_id: 1 }] });
apiMocks.mixCalculatorSessions.mockResolvedValue([{ id: 11 }]);
apiMocks.mixCalculatorSession.mockResolvedValue({ id: 12 });
apiMocks.products.mockResolvedValue([{ id: 3 }]);
apiMocks.productCosts.mockResolvedValue([{ id: 4 }]);
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
@@ -108,6 +118,31 @@ describe('route loaders use the SvelteKit fetch argument', () => {
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the mix calculator history loader', async () => {
await mixCalculatorLoad({ fetch: fetcher } as never);
expect(apiMocks.mixCalculatorSessions).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the new mix calculator loader', async () => {
await mixCalculatorNewLoad({ fetch: fetcher } as never);
expect(apiMocks.mixCalculatorOptions).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the mix calculator detail loader', async () => {
await mixCalculatorDetailLoad({ params: { id: '12' }, fetch: fetcher } as never);
expect(apiMocks.mixCalculatorSession).toHaveBeenCalledWith(12, fetcher);
expect(apiMocks.mixCalculatorOptions).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the mix calculator print loader', async () => {
await mixCalculatorPrintLoad({ params: { id: '12' }, fetch: fetcher } as never);
expect(apiMocks.mixCalculatorSession).toHaveBeenCalledWith(12, fetcher);
});
it('passes fetch through the scenarios loader', async () => {
await scenariosLoad({ fetch: fetcher } as never);
@@ -0,0 +1,351 @@
<script lang="ts">
import { clientSession, hasModuleAccess } from '$lib/session';
import type { MixCalculatorSession } from '$lib/types';
let { data }: { data: { sessions: MixCalculatorSession[] } } = $props();
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
function formatDate(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function formatNumber(value: number, digits = 2) {
return value.toFixed(digits);
}
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Mix Calculator</p>
<h2>Saved production sessions</h2>
<p>Each session preserves the scaled raw material output so it can be reopened or printed later without relying on live recipe changes.</p>
</div>
{#if canEdit}
<a class="primary-button" href="/mix-calculator/new">New mix session</a>
{/if}
</section>
<section class="metric-row">
<article class="metric-card">
<span>Saved Sessions</span>
<strong>{data.sessions.length}</strong>
<p>Visible under your access scope</p>
</article>
<article class="metric-card">
<span>Total Planned Kg</span>
<strong>{formatNumber(data.sessions.reduce((sum, session) => sum + session.total_kg, 0), 2)}</strong>
<p>Across the visible history</p>
</article>
<article class="metric-card">
<span>Sessions With Warnings</span>
<strong>{data.sessions.filter((session) => session.warnings.length).length}</strong>
<p>Fractional bag outputs need review</p>
</article>
</section>
<section class="table-card">
<div class="table-toolbar">
<div>
<h3>Session history</h3>
<p>Operators see their own sessions. Superadmins and admins see the full client history.</p>
</div>
</div>
{#if data.sessions.length}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Session</th>
<th>Client / Product</th>
<th>Batch</th>
<th>Bags</th>
<th>Prepared by</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{#each data.sessions as session}
<tr>
<td data-label="Session">
<strong>{session.session_number}</strong>
<span>{session.mix_name}</span>
</td>
<td data-label="Client / Product">
<strong>{session.product_name}</strong>
<span>{session.client_name}</span>
</td>
<td data-label="Batch">{formatNumber(session.batch_size_kg, 2)}kg</td>
<td data-label="Bags">
{formatNumber(session.total_bags, 2)}
{#if session.warnings.length}
<span class="warning-pill">Warn</span>
{/if}
</td>
<td data-label="Prepared by">{session.prepared_by_name}</td>
<td data-label="Updated">{formatDate(session.updated_at)}</td>
<td data-label="Open">
<div class="row-actions">
<a href={`/mix-calculator/${session.id}`}>Open</a>
<a href={`/mix-calculator/${session.id}/print`}>Print</a>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="empty-state">
<strong>No saved sessions yet</strong>
<span>{canEdit ? 'Run a new calculation and save it to start your session history.' : 'No sessions are visible under your access scope yet.'}</span>
</div>
{/if}
</section>
<style>
h2,
h3,
p {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.metric-row,
.table-card {
margin-bottom: 1.25rem;
}
.page-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 15ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.table-toolbar p,
tbody span {
color: var(--muted);
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.78rem 0.96rem;
border-radius: 0.9rem;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
color: #fff;
font-weight: 600;
}
.metric-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.metric-card,
.table-card {
border: 1px solid var(--line);
border-radius: 1.3rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.metric-card {
padding: 1.15rem 1.2rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.9rem;
}
.metric-card strong {
display: block;
margin: 0.55rem 0 0.3rem;
font-size: 1.9rem;
}
.table-card {
padding: 1.2rem;
}
.table-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
min-width: 54rem;
border-collapse: separate;
border-spacing: 0 0.75rem;
}
th,
td {
padding: 1rem;
text-align: left;
white-space: nowrap;
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
tbody td {
background: var(--panel-soft);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
tbody td:first-child {
border-left: 1px solid var(--line);
border-radius: 1rem 0 0 1rem;
}
tbody td:last-child {
border-right: 1px solid var(--line);
border-radius: 0 1rem 1rem 0;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.8rem;
}
.row-actions a {
font-weight: 600;
}
.warning-pill {
display: inline-flex;
margin-left: 0.55rem;
padding: 0.25rem 0.5rem;
border-radius: 999px;
background: #fff6e6;
color: #8b5b1e;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.empty-state {
display: grid;
gap: 0.2rem;
padding: 1rem;
border-radius: 1rem;
background: var(--panel-soft);
}
@media (max-width: 900px) {
.metric-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.page-intro,
.table-toolbar {
flex-direction: column;
align-items: flex-start;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
table {
min-width: 0;
border-spacing: 0;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.9rem;
}
tbody tr {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
tbody td {
padding: 0.78rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
tbody td:first-child,
tbody td:last-child {
border: none;
border-radius: 0;
}
tbody td + td {
border-top: 1px solid var(--line);
}
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;
}
}
</style>
@@ -0,0 +1,22 @@
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
sessions: []
};
}
const session = getStoredClientSession();
try {
return {
sessions: hasModuleAccess(session, 'mix_calculator') ? await api.mixCalculatorSessions(fetch) : []
};
} catch {
return {
sessions: []
};
}
}
@@ -0,0 +1,61 @@
<script lang="ts">
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
let { data } = $props();
</script>
{#if data.session}
<MixCalculatorWorkspace initialSession={data.session} options={data.options} />
{:else}
<section class="locked-card">
<p class="eyebrow">Mix Calculator</p>
<h2>Session unavailable.</h2>
<p>The requested mix calculator session could not be loaded with the current access scope.</p>
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
</section>
{/if}
<style>
h2,
p {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.locked-card {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.locked-card h2 {
margin-top: 0.35rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
}
.locked-card p:last-of-type {
margin-top: 0.45rem;
color: var(--muted);
}
.secondary-button {
display: inline-flex;
margin-top: 1rem;
padding: 0.78rem 0.92rem;
border: 1px solid var(--line-strong);
border-radius: 0.88rem;
background: #fff;
color: #304038;
font-weight: 600;
}
</style>
@@ -0,0 +1,39 @@
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
export async function load({ params, fetch }) {
if (!hasStoredClientSession()) {
return {
session: null,
options: { clients: [], products: [] }
};
}
const session = getStoredClientSession();
const canView = hasModuleAccess(session, 'mix_calculator');
const canEdit = hasModuleAccess(session, 'mix_calculator', 'edit');
if (!canView) {
return {
session: null,
options: { clients: [], products: [] }
};
}
try {
const [savedSession, options] = await Promise.all([
api.mixCalculatorSession(Number(params.id), fetch),
canEdit ? api.mixCalculatorOptions(fetch) : Promise.resolve({ clients: [], products: [] })
]);
return {
session: savedSession,
options
};
} catch {
return {
session: null,
options: { clients: [], products: [] }
};
}
}
@@ -0,0 +1,61 @@
<script lang="ts">
import MixCalculatorPrintSheet from '$lib/components/MixCalculatorPrintSheet.svelte';
let { data } = $props();
</script>
{#if data.session}
<MixCalculatorPrintSheet session={data.session} />
{:else}
<section class="locked-card">
<p class="eyebrow">Mix Calculator</p>
<h2>Printable session unavailable.</h2>
<p>The saved session could not be loaded for printing.</p>
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
</section>
{/if}
<style>
h2,
p {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.locked-card {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.locked-card h2 {
margin-top: 0.35rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
}
.locked-card p:last-of-type {
margin-top: 0.45rem;
color: var(--muted);
}
.secondary-button {
display: inline-flex;
margin-top: 1rem;
padding: 0.78rem 0.92rem;
border: 1px solid var(--line-strong);
border-radius: 0.88rem;
background: #fff;
color: #304038;
font-weight: 600;
}
</style>
@@ -0,0 +1,28 @@
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
export async function load({ params, fetch }) {
if (!hasStoredClientSession()) {
return {
session: null
};
}
const session = getStoredClientSession();
if (!hasModuleAccess(session, 'mix_calculator')) {
return {
session: null
};
}
try {
return {
session: await api.mixCalculatorSession(Number(params.id), fetch)
};
} catch {
return {
session: null
};
}
}
@@ -0,0 +1,6 @@
<script lang="ts">
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
let { data } = $props();
</script>
<MixCalculatorWorkspace options={data.options} />
@@ -0,0 +1,24 @@
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
options: { clients: [], products: [] }
};
}
const session = getStoredClientSession();
try {
return {
options: hasModuleAccess(session, 'mix_calculator', 'edit')
? await api.mixCalculatorOptions(fetch)
: { clients: [], products: [] }
};
} catch {
return {
options: { clients: [], products: [] }
};
}
}