This commit is contained in:
2026-06-09 21:28:53 +12:00
parent daa6e60a69
commit 349e4a4b5b
61 changed files with 6404 additions and 1382 deletions
+2 -16
View File
@@ -3,6 +3,8 @@
import '@fontsource/inter/latin-500.css';
import '@fontsource/inter/latin-600.css';
import '@fontsource/inter/latin-700.css';
import '$lib/styles/theme.css';
import '$lib/theme';
import { beforeNavigate, afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import AdminShell from '$lib/components/AdminShell.svelte';
@@ -51,19 +53,3 @@
<Toast />
<style>
:global(html, body) {
font-family: "Inter", "Segoe UI", sans-serif;
}
:global(button),
:global(input),
:global(select),
:global(textarea) {
font: inherit;
}
:global(h1, h2, h3, h4, h5, h6) {
font-family: "Inter", "Segoe UI", sans-serif;
}
</style>
+471 -17
View File
@@ -6,7 +6,18 @@
import type { DashboardSummary } from '$lib/types';
import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access';
import packageInfo from '../../package.json';
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
import {
ArrowUpRight,
BadgeDollarSign,
Factory,
PackageCheck,
Scale,
Sun,
Sunrise,
Sunset,
TriangleAlert,
Moon
} from 'lucide-svelte';
import { tick } from 'svelte';
type Segment = {
@@ -115,6 +126,13 @@
return `$${value.toFixed(digits)}`;
}
function kg(value: number | null | undefined) {
if (value === null || value === undefined) {
return '0 kg';
}
return `${value.toLocaleString(undefined, { maximumFractionDigits: 0 })} kg`;
}
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No date';
@@ -314,6 +332,7 @@
const trendArea = $derived(areaPath(trendSeries));
const trendFocus = $derived(focusMarker(trendSeries));
const topProducts = $derived(summary?.products?.top_products ?? []);
const operations = $derived(summary?.operations ?? null);
const focusCards = $derived(buildFocusCards(summary));
const loading = $derived(summary === null);
const greeting = $derived(timeOfDay());
@@ -465,7 +484,7 @@
</div>
<div class="intro-actions">
{#if canOpenEditor($clientSession)}
<a class="primary-button" href="/editor">Open Editor</a>
<a class="primary-button" href="/editor">Open Mix Editor</a>
{/if}
</div>
</section>
@@ -610,6 +629,146 @@
</div>
</section>
<section class="operations-report">
<div class="card-toolbar operations-toolbar">
<div class="operations-heading">
<span class="operations-icon"><Factory size={24} strokeWidth={2.2} /></span>
<div>
<h3>Production And Pricing</h3>
<p>Throughput and Product Costing for {operations?.period_label ?? 'this month'}</p>
</div>
</div>
<a class="secondary-button compact operations-link" href="/product-costing">
Open Product Costing
<ArrowUpRight size={15} strokeWidth={2.3} />
</a>
</div>
<div class="operations-graphic" aria-hidden="true">
<div class="operations-graphic-labels">
<span>Throughput</span>
<span>Costing</span>
<span>Pricing</span>
</div>
<div class="operations-graphic-track">
<span class="operation-node production-node"></span>
<span class="operation-track"></span>
<span class="operation-bars">
<i></i>
<i></i>
<i></i>
</span>
<span class="operation-node pricing-node"></span>
</div>
</div>
<div class="operations-metrics">
<article class="produced">
<div class="metric-label">
<span>Produced</span>
<span class="metric-symbol"><PackageCheck size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="6rem" height="1.5rem" /></strong>
{:else}
<strong>{kg(operations?.total_kg)}</strong>
{/if}
<p>{operations?.entry_count ?? 0} throughput entries</p>
</article>
<article class="bags">
<div class="metric-label">
<span>Bags</span>
<span class="metric-symbol"><Scale size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="5rem" height="1.5rem" /></strong>
{:else}
<strong>{(operations?.total_bags ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</strong>
{/if}
<p>Logged as bag runs</p>
</article>
<article class="value">
<div class="metric-label">
<span>Wholesale Value</span>
<span class="metric-symbol"><BadgeDollarSign size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="7rem" height="1.5rem" /></strong>
{:else}
<strong>{currency(operations?.estimated_wholesale_value)}</strong>
{/if}
<p>{operations?.priced_entry_count ?? 0} priced entries</p>
</article>
<article class:warning={(operations?.pricing_issues?.total ?? 0) > 0}>
<div class="metric-label">
<span>Pricing Issues</span>
<span class="metric-symbol"><TriangleAlert size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="4rem" height="1.5rem" /></strong>
{:else}
<strong>{operations?.pricing_issues?.total ?? 0}</strong>
{/if}
<p>Products needing review</p>
</article>
</div>
<div class="operations-grid">
<article>
<div class="mini-heading">
<strong>Top Produced</strong>
<span>By kg</span>
</div>
<div class="report-list">
{#if loading}
{#each Array(4) as _}
<div><Skeleton width="10rem" /><Skeleton width="4rem" /></div>
{/each}
{:else if operations?.top_products?.length}
{#each operations.top_products as product}
<div>
<span>
<strong>{product.product_name}</strong>
<small>{product.client_name ?? 'No client'} · {product.entries} entries</small>
</span>
<em>{kg(product.kg)}</em>
</div>
{/each}
{:else}
<p>No throughput recorded this month.</p>
{/if}
</div>
</article>
<article>
<div class="mini-heading">
<strong>Produced But Not Priced</strong>
<span>Fix these first</span>
</div>
<div class="report-list">
{#if loading}
{#each Array(4) as _}
<div><Skeleton width="10rem" /><Skeleton width="4rem" /></div>
{/each}
{:else if operations?.produced_not_priced?.length}
{#each operations.produced_not_priced as product}
<div>
<span>
<strong>{product.product_name}</strong>
<small>{product.warnings[0] ?? product.status}</small>
</span>
<em>{kg(product.kg)}</em>
</div>
{/each}
{:else}
<p>All produced products have usable pricing.</p>
{/if}
</div>
</article>
</div>
</section>
<section class="analysis-grid">
<article class="panel-card chart-card">
<div class="card-toolbar">
@@ -1094,6 +1253,7 @@
.workspace-banner,
.focus-row,
.dashboard-grid,
.operations-report,
.analysis-grid,
.detail-grid {
margin-bottom: 1.25rem;
@@ -1177,15 +1337,15 @@
.primary-button {
border: none;
color: #fff;
color: var(--color-on-brand);
background: var(--color-brand);
box-shadow: none;
}
.secondary-button {
border: 1px solid var(--line-strong);
color: #304038;
background: #fff;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
background: var(--color-bg-surface);
}
.secondary-button.compact {
@@ -1206,6 +1366,276 @@
padding: 1.2rem;
}
.operations-report {
position: relative;
overflow: hidden;
padding: 1.2rem;
border: 1px solid var(--color-border);
border-radius: 1.4rem;
background: var(--color-bg-surface);
box-shadow: var(--shadow);
}
.operations-toolbar {
margin-bottom: 0.9rem;
}
.operations-heading {
display: flex;
align-items: center;
gap: 0.85rem;
min-width: 0;
}
.operations-heading h3 {
margin: 0 0 0.18rem;
color: var(--text);
}
.operations-heading p {
margin: 0;
color: var(--muted);
}
.operations-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.1rem;
height: 3.1rem;
flex-shrink: 0;
border: 1px solid rgba(21, 128, 61, 0.18);
border-radius: 1rem;
background: #f0f8f3;
color: #0f6f3d;
box-shadow: inset 0 -0.45rem 1rem rgba(21, 128, 61, 0.08);
}
.operations-graphic {
display: grid;
gap: 0.5rem;
margin-bottom: 0.95rem;
padding: 0.75rem 0.85rem;
border: 1px solid rgba(214, 228, 220, 0.86);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.62);
box-shadow: inset 0 -0.6rem 1.4rem rgba(21, 128, 61, 0.06);
}
.operations-graphic-labels,
.operations-graphic-track {
display: grid;
grid-template-columns: 2.25rem minmax(4rem, 1fr) 4.5rem 2.25rem;
align-items: center;
gap: 0.45rem;
}
.operations-graphic-labels {
grid-template-columns: repeat(3, minmax(0, 1fr));
color: #365243;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.operations-graphic-labels span:nth-child(2) {
text-align: center;
}
.operations-graphic-labels span:nth-child(3) {
text-align: right;
}
.operation-node {
display: block;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.8rem;
background: #0f6f3d;
box-shadow: inset 0 -0.35rem 0.65rem rgba(0, 0, 0, 0.12);
}
.pricing-node {
background: #e7ad3c;
}
.operation-track {
align-self: center;
height: 0.24rem;
border-radius: 999px;
background: linear-gradient(90deg, rgba(21, 128, 61, 0.22), rgba(59, 130, 196, 0.58), rgba(231, 173, 60, 0.55));
}
.operation-bars {
display: flex;
align-items: end;
justify-content: center;
gap: 0.28rem;
height: 2.4rem;
}
.operation-bars i {
display: block;
width: 0.7rem;
border-radius: 999px 999px 0.25rem 0.25rem;
background: #3b82c4;
}
.operation-bars i:nth-child(1) {
height: 1.2rem;
}
.operation-bars i:nth-child(2) {
height: 2rem;
background: #15803d;
}
.operation-bars i:nth-child(3) {
height: 1.55rem;
background: #e7ad3c;
}
.operations-link {
gap: 0.45rem;
white-space: nowrap;
}
.operations-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.operations-metrics article {
position: relative;
overflow: hidden;
padding: 0.95rem;
border: 1px solid rgba(214, 228, 220, 0.94);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.84);
box-shadow: 0 0.75rem 1.4rem rgba(43, 57, 47, 0.05);
}
.operations-metrics article::after {
content: '';
position: absolute;
right: 0.85rem;
bottom: 0;
left: 0.85rem;
height: 0.24rem;
border-radius: 999px 999px 0 0;
background: #15803d;
opacity: 0.65;
}
.operations-metrics article.bags::after {
background: #3b82c4;
}
.operations-metrics article.value::after {
background: #e7ad3c;
}
.operations-metrics article.warning {
border-color: rgba(231, 173, 60, 0.44);
background: #fff8e8;
}
.operations-metrics article.warning::after {
background: #b45309;
}
.metric-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.metric-symbol {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #0f6f3d;
}
.bags .metric-symbol {
color: #2f6f9f;
}
.value .metric-symbol {
color: #9a6718;
}
.warning .metric-symbol {
color: #9a3412;
}
.metric-label span,
.operations-metrics p,
.mini-heading span,
.report-list small {
color: var(--muted);
}
.operations-metrics strong {
display: block;
margin: 0.4rem 0 0.25rem;
font-size: 1.55rem;
line-height: 1;
}
.operations-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.operations-grid > article {
padding-top: 0.9rem;
border-top: 1px solid var(--line);
}
.mini-heading,
.report-list div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.mini-heading {
margin-bottom: 0.65rem;
}
.report-list {
display: grid;
gap: 0.55rem;
}
.report-list div {
padding: 0.65rem 0;
border-bottom: 1px solid var(--line);
}
.report-list div:last-child {
border-bottom: none;
}
.report-list strong,
.report-list small {
display: block;
}
.report-list em {
flex-shrink: 0;
font-style: normal;
font-weight: 700;
}
.focus-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1249,15 +1679,15 @@
}
.focus-card.positive {
background: linear-gradient(180deg, #f6fbf7 0%, #edf8f0 100%);
background: var(--color-success-tint);
}
.focus-card.warning {
background: linear-gradient(180deg, #fffaf3 0%, #fff3e3 100%);
background: var(--color-warning-tint);
}
.focus-card.neutral {
background: linear-gradient(180deg, #f7faf8 0%, #eff4f1 100%);
background: var(--panel-soft);
}
.focus-code {
@@ -1267,8 +1697,8 @@
align-items: center;
justify-content: center;
border-radius: 0.75rem;
color: #fff;
background: var(--green-deep);
color: var(--color-on-brand);
background: var(--color-brand);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.05em;
@@ -1369,8 +1799,9 @@
}
.toggle-pill .active {
color: #fff;
background: var(--green-deep);
color: var(--color-text-primary);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
}
.market-layout {
@@ -1410,7 +1841,11 @@
height: 8.5rem;
flex-shrink: 0;
border-radius: 1.4rem;
background: linear-gradient(180deg, #d7f0ff 0%, #fff0bf 45%, #89c762 46%, #3f8e3d 100%);
background: linear-gradient(
150deg,
var(--color-brand) 0%,
color-mix(in srgb, var(--color-brand) 70%, var(--color-text-primary)) 100%
);
overflow: hidden;
}
@@ -1421,7 +1856,12 @@
width: 2.9rem;
height: 2.9rem;
border-radius: 999px;
background: radial-gradient(circle at 35% 35%, #fff8cc 0%, #ffd865 58%, #f4ae1f 100%);
background: radial-gradient(
circle at 38% 38%,
color-mix(in srgb, var(--color-on-brand) 38%, transparent) 0%,
color-mix(in srgb, var(--color-on-brand) 12%, transparent) 62%,
transparent 72%
);
}
.field-stripe {
@@ -1430,7 +1870,7 @@
right: -10%;
height: 22%;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
background: color-mix(in srgb, var(--color-on-brand) 16%, transparent);
transform: rotate(-18deg);
}
@@ -1864,10 +2304,15 @@
@media (max-width: 1120px) {
.analysis-grid,
.detail-grid {
.detail-grid,
.operations-grid {
grid-template-columns: 1fr;
}
.operations-link {
justify-self: start;
}
.focus-grid {
min-width: 0;
}
@@ -1889,6 +2334,10 @@
.focus-grid {
grid-template-columns: 1fr;
}
.operations-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
@@ -1935,10 +2384,15 @@
}
.preview-facts,
.operations-metrics,
.signin-form {
grid-template-columns: 1fr;
}
.operations-graphic {
width: 100%;
}
.field-emblem {
width: 6.5rem;
height: 6.5rem;
+2 -1
View File
@@ -8,7 +8,8 @@ const EMPTY_SUMMARY: DashboardSummary = {
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] }
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] },
operations: null
};
// Streaming load: the route shell paints immediately and the dashboard fills
+203 -135
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import type { EditorProductFormula, EditorProductIngredient, EditorProductRow, RawMaterial } from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
@@ -345,94 +346,99 @@
});
</script>
<section class="editor">
<div class="status-band">
<div class="editor-status">
<span>
<strong>Editor</strong>
<small>{visibleRows.length} products across {uniqueMixCount} mixes</small>
</span>
<AppSecondaryRailLayout>
{#snippet rail()}
<div class="filter-rail" aria-label="Mix Editor filters">
<p class="rail-label">Mix Editor</p>
<div class="rail-identity">
<div class="rail-avatar" aria-hidden="true">
<ListFilter size={16} strokeWidth={1.8} />
</div>
<div class="rail-identity-text">
<p class="identity-name">Filter products</p>
<p class="identity-role">{visibleRows.length} matching rows</p>
</div>
</div>
<div class="filter-rail-body">
<label class="filter-search">
<span>Search</span>
<div class="search-input">
<Search size={17} strokeWidth={2.2} />
<input bind:value={query} type="search" placeholder="Client, product, mix, item ID, unit" />
</div>
</label>
<div class="status-filter" role="group" aria-label="Status filter">
<span class="field-label">Status</span>
<div class="segmented-control">
<button type="button" class:active={visibilityFilter === 'visible'} aria-pressed={visibilityFilter === 'visible'} onclick={() => setVisibilityFilter('visible')}>Active</button>
<button type="button" class:active={visibilityFilter === 'hidden'} aria-pressed={visibilityFilter === 'hidden'} onclick={() => setVisibilityFilter('hidden')}>Inactive</button>
<button type="button" class:active={visibilityFilter === 'all'} aria-pressed={visibilityFilter === 'all'} onclick={() => setVisibilityFilter('all')}>All</button>
</div>
</div>
<label>
<span>Client</span>
<select bind:value={clientFilter}>
<option value="all">All clients</option>
{#each clientOptions as client}
<option value={client}>{client}</option>
{/each}
</select>
</label>
<label>
<span>Product</span>
<input bind:value={productFilter} type="search" placeholder="Product name" />
</label>
<label>
<span>Pack</span>
<select bind:value={packFilter}>
<option value="all">All packs</option>
{#each packOptions as option}
<option value={option}>{option}</option>
{/each}
</select>
</label>
{#if filtersActive}
<button type="button" class="clear-button rail-clear" onclick={clearFilters}>
<X size={16} strokeWidth={2.4} /> Clear filters
</button>
{/if}
</div>
</div>
{/snippet}
<dl class="facts">
<div class="fact">
<dt>Products</dt>
<dd>{visibleRows.length}</dd>
</div>
<div class="fact">
<dt>Mixes</dt>
<dd>{uniqueMixCount}</dd>
</div>
<div class="fact">
<dt>Unsaved</dt>
<dd>{dirtyCount}</dd>
</div>
</dl>
</div>
<section class="filters" aria-label="Editor filters">
<div class="filter-head">
<div>
<span class="section-label">
<ListFilter size={15} strokeWidth={2.2} />
Filters
<section class="editor">
<div class="status-band">
<div class="editor-status">
<span>
<strong>Mix Editor</strong>
<small>{visibleRows.length} products across {uniqueMixCount} mixes</small>
</span>
<strong>{visibleRows.length} matching products</strong>
</div>
{#if filtersActive}
<button type="button" class="clear-button" onclick={clearFilters}>
<X size={16} strokeWidth={2.4} /> Clear filters
</button>
{/if}
<dl class="facts">
<div class="fact">
<dt>Products</dt>
<dd>{visibleRows.length}</dd>
</div>
<div class="fact">
<dt>Mixes</dt>
<dd>{uniqueMixCount}</dd>
</div>
<div class="fact">
<dt>Unsaved</dt>
<dd>{dirtyCount}</dd>
</div>
</dl>
</div>
<div class="filter-grid">
<label class="filter-search">
<span>Search</span>
<div class="search-input">
<Search size={17} strokeWidth={2.2} />
<input bind:value={query} type="search" placeholder="Client, product, mix, item ID, unit" />
</div>
</label>
<div class="status-filter" role="group" aria-label="Status filter">
<span class="field-label">Status</span>
<div class="segmented-control">
<button type="button" class:active={visibilityFilter === 'visible'} aria-pressed={visibilityFilter === 'visible'} onclick={() => setVisibilityFilter('visible')}>Active</button>
<button type="button" class:active={visibilityFilter === 'hidden'} aria-pressed={visibilityFilter === 'hidden'} onclick={() => setVisibilityFilter('hidden')}>Inactive</button>
<button type="button" class:active={visibilityFilter === 'all'} aria-pressed={visibilityFilter === 'all'} onclick={() => setVisibilityFilter('all')}>All</button>
</div>
</div>
<label>
<span>Client</span>
<select bind:value={clientFilter}>
<option value="all">All clients</option>
{#each clientOptions as client}
<option value={client}>{client}</option>
{/each}
</select>
</label>
<label>
<span>Product</span>
<input bind:value={productFilter} type="search" placeholder="Product name" />
</label>
<label>
<span>Pack</span>
<select bind:value={packFilter}>
<option value="all">All packs</option>
{#each packOptions as option}
<option value={option}>{option}</option>
{/each}
</select>
</label>
</div>
</section>
<div class="pagination-bar" aria-label="Product table pagination">
<div class="pagination-bar" aria-label="Product table pagination">
<span>{pageStart}-{pageEnd} of {visibleRows.length}</span>
<label class="page-size">
<span>Rows</span>
@@ -452,9 +458,9 @@
<ChevronRight size={16} strokeWidth={2.4} />
</button>
</div>
</div>
</div>
<div class="log">
<div class="log">
<div class="log-head" aria-hidden="true">
<span>Client</span>
<span>ID</span>
@@ -576,15 +582,104 @@
<button type="button" class="clear-button" onclick={clearFilters}>Clear filters</button>
</div>
{/each}
</div>
</section>
</div>
</section>
</AppSecondaryRailLayout>
<style>
.editor {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0 2rem;
gap: 0.9rem;
min-height: 100%;
padding: 1rem 1.15rem 2rem;
background: #e8eee9;
}
:global(.secondary-rail-layout) {
margin-bottom: 1.25rem;
}
:global(.secondary-rail-layout-panel),
:global(.secondary-rail-layout-content) {
background: #e8eee9;
}
.filter-rail {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
height: 100%;
min-height: calc(100vh - 8.5rem);
background: color-mix(in srgb, var(--panel-soft) 46%, #dfe7e1);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.rail-label {
margin: 0;
padding: 1rem 1rem 0.15rem;
color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.rail-identity {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 1rem 1.15rem;
border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
}
.rail-avatar {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.15rem;
height: 2.15rem;
border: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--panel) 80%, #edf2ee);
color: #6b786f;
}
.rail-identity-text {
min-width: 0;
}
.identity-name,
.identity-role {
margin: 0;
}
.identity-name {
color: #526059;
font-size: 0.8rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.identity-role {
color: #8a9790;
font-size: 0.72rem;
}
.filter-rail-body {
display: grid;
gap: 0.85rem;
padding: 0.8rem 0.8rem 1rem;
}
.rail-clear {
width: 100%;
}
.status-band {
@@ -706,39 +801,6 @@
padding-inline: 0.2rem;
}
.filters {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 0.9rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 0.9rem;
}
.filter-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.8rem;
border-bottom: 1px solid var(--color-divider);
}
.filter-head div {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.filter-head strong {
color: var(--color-text-primary);
font-size: 1rem;
font-weight: 700;
}
.section-label,
.field-label {
display: inline-flex;
align-items: center;
@@ -748,13 +810,6 @@
font-weight: 700;
}
.filter-grid {
display: grid;
grid-template-columns: minmax(18rem, 1.4fr) minmax(15rem, 0.95fr) minmax(13rem, 0.8fr) minmax(13rem, 0.9fr) minmax(9rem, 0.5fr);
gap: 0.65rem;
align-items: end;
}
.pagination-bar {
display: flex;
align-items: center;
@@ -1130,8 +1185,7 @@
justify-content: flex-start;
}
.ingredient-grid,
.filter-grid {
.ingredient-grid {
grid-template-columns: 1fr;
}
@@ -1140,14 +1194,28 @@
}
}
@media (max-width: 760px) {
.filter-head {
align-items: stretch;
flex-direction: column;
@media (max-width: 980px) {
.filter-rail {
position: static;
min-height: auto;
height: auto;
border-right: none;
overflow: visible;
}
.filter-head .clear-button {
width: 100%;
.filter-rail-body {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: end;
}
.filter-search {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.filter-rail-body {
grid-template-columns: 1fr;
}
.facts {
+11 -6
View File
@@ -11,6 +11,7 @@ const apiMocks = vi.hoisted(() => ({
productCosts: vi.fn(),
scenarios: vi.fn(),
dataQuality: vi.fn(),
dashboardSummary: vi.fn(),
clientAccess: vi.fn(),
clientAccessExport: vi.fn()
}));
@@ -61,6 +62,13 @@ describe('route loaders use the SvelteKit fetch argument', () => {
apiMocks.productCosts.mockResolvedValue([{ id: 4 }]);
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
apiMocks.dataQuality.mockResolvedValue([{ id: 6 }]);
apiMocks.dashboardSummary.mockResolvedValue({
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] },
operations: null
});
apiMocks.clientAccess.mockResolvedValue([{ id: 7 }]);
apiMocks.clientAccessExport.mockResolvedValue({
generated_at: '',
@@ -74,13 +82,10 @@ describe('route loaders use the SvelteKit fetch argument', () => {
});
it('passes fetch through the home page loader', async () => {
await homeLoad({ fetch: fetcher } as never);
const result = homeLoad({ fetch: fetcher } as never);
await result.summary;
expect(apiMocks.rawMaterials).toHaveBeenCalledWith(fetcher);
expect(apiMocks.mixes).toHaveBeenCalledWith(fetcher);
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
expect(apiMocks.scenarios).toHaveBeenCalledWith(fetcher);
expect(apiMocks.dataQuality).toHaveBeenCalledWith(fetcher);
expect(apiMocks.dashboardSummary).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the raw materials loader', async () => {
@@ -63,7 +63,7 @@
<thead>
<tr>
<th>Session</th>
<th>Client / Product</th>
<th>Client / Mix</th>
<th>Batch</th>
<th>Bags</th>
<th>Prepared by</th>
@@ -78,7 +78,7 @@
<strong>{session.session_number}</strong>
<span>{session.mix_name}</span>
</td>
<td data-label="Client / Product">
<td data-label="Client / Mix">
<strong>{session.product_name}</strong>
<span>{session.client_name}</span>
</td>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canOpenProductCosting, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { items: [], inputs: null };
}
const session = getStoredClientSession();
if (!canOpenProductCosting(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const canRead = hasModuleAccess(session, 'products') || session?.role === 'internal';
if (!canRead) {
return { items: [], inputs: null };
}
const [items, inputs] = await Promise.all([
api.productCostingItems(fetch),
api.productCostingInputs(fetch)
]);
return { items, inputs };
} catch {
return { items: [], inputs: null };
}
}
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@
} from '$lib/types';
import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte';
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
import { toNum } from '$lib/format';
let { data } = $props<{ data: { products: ThroughputProduct[] } }>();
const products = $derived(data.products ?? []);
@@ -53,13 +54,6 @@
}
});
function toNum(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
function resetExceptDateAndStaff() {
productId = '';
bagSize = '';