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
+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: [] }
};
}
}