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