Mix calculator
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [] }
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user