588 lines
14 KiB
Svelte
588 lines
14 KiB
Svelte
|
|
<script lang="ts">
|
||
|
|
import { api } from '$lib/api';
|
||
|
|
import { operatorSession } from '$lib/session';
|
||
|
|
import type { Mix, ProductCostBreakdown, RawMaterial } from '$lib/types';
|
||
|
|
|
||
|
|
let { data } = $props();
|
||
|
|
|
||
|
|
let email = $state('operator@example.com');
|
||
|
|
let password = $state('changeme');
|
||
|
|
let isLoggingIn = $state(false);
|
||
|
|
let loginError = $state('');
|
||
|
|
|
||
|
|
function currency(value: number | null | undefined, digits = 2) {
|
||
|
|
if (value === null || value === undefined) {
|
||
|
|
return 'N/A';
|
||
|
|
}
|
||
|
|
|
||
|
|
return `$${value.toFixed(digits)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function findHighestProduct(products: ProductCostBreakdown[]) {
|
||
|
|
return [...products].sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
function findMostExpensiveMix(mixes: Mix[]) {
|
||
|
|
return [...mixes].sort((left, right) => (right.mix_cost_per_kg ?? 0) - (left.mix_cost_per_kg ?? 0))[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
function findLatestMaterial(materials: RawMaterial[]) {
|
||
|
|
return [...materials].sort((left, right) => {
|
||
|
|
const leftDate = left.current_price?.effective_date ?? '';
|
||
|
|
const rightDate = right.current_price?.effective_date ?? '';
|
||
|
|
return rightDate.localeCompare(leftDate);
|
||
|
|
})[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleLogin(event: SubmitEvent) {
|
||
|
|
event.preventDefault();
|
||
|
|
loginError = '';
|
||
|
|
isLoggingIn = true;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const session = await api.login(email, password);
|
||
|
|
operatorSession.set(session);
|
||
|
|
} catch (error) {
|
||
|
|
loginError = error instanceof Error ? error.message : 'Unable to sign in';
|
||
|
|
} finally {
|
||
|
|
isLoggingIn = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const featuredProduct = $derived(findHighestProduct(data.productCosts));
|
||
|
|
const featuredMix = $derived(findMostExpensiveMix(data.mixes));
|
||
|
|
const featuredMaterial = $derived(findLatestMaterial(data.rawMaterials));
|
||
|
|
</script>
|
||
|
|
|
||
|
|
{#if !$operatorSession}
|
||
|
|
<section class="hero-grid">
|
||
|
|
<article class="hero-panel intro">
|
||
|
|
<p class="eyebrow">Costing control room</p>
|
||
|
|
<h1>Sign in to manage raw materials and push updates through Mix Master automatically.</h1>
|
||
|
|
<p class="lede">
|
||
|
|
This workflow is for operators maintaining input costs. Update a raw material once, then review the refreshed
|
||
|
|
mix cost per kg and finished product pricing in the same session.
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<div class="workflow">
|
||
|
|
<article>
|
||
|
|
<span>01</span>
|
||
|
|
<h2>Log in</h2>
|
||
|
|
<p>Enter the operator account to unlock material maintenance and costing review.</p>
|
||
|
|
</article>
|
||
|
|
<article>
|
||
|
|
<span>02</span>
|
||
|
|
<h2>Update inputs</h2>
|
||
|
|
<p>Record a new market value or waste percentage for any raw material.</p>
|
||
|
|
</article>
|
||
|
|
<article>
|
||
|
|
<span>03</span>
|
||
|
|
<h2>Review impact</h2>
|
||
|
|
<p>Check Mix Master and downstream product pricing immediately after the save.</p>
|
||
|
|
</article>
|
||
|
|
</div>
|
||
|
|
</article>
|
||
|
|
|
||
|
|
<article class="hero-panel login-panel">
|
||
|
|
<div class="panel-heading">
|
||
|
|
<div>
|
||
|
|
<p class="eyebrow">Operator login</p>
|
||
|
|
<h2>Use the seeded prototype account</h2>
|
||
|
|
</div>
|
||
|
|
<span class="badge">Backend-backed</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<form class="login-form" onsubmit={handleLogin}>
|
||
|
|
<label>
|
||
|
|
Email
|
||
|
|
<input bind:value={email} type="email" autocomplete="username" />
|
||
|
|
</label>
|
||
|
|
|
||
|
|
<label>
|
||
|
|
Password
|
||
|
|
<input bind:value={password} type="password" autocomplete="current-password" />
|
||
|
|
</label>
|
||
|
|
|
||
|
|
{#if loginError}
|
||
|
|
<p class="form-error">{loginError}</p>
|
||
|
|
{/if}
|
||
|
|
|
||
|
|
<button type="submit" disabled={isLoggingIn}>
|
||
|
|
{isLoggingIn ? 'Signing in...' : 'Sign in'}
|
||
|
|
</button>
|
||
|
|
</form>
|
||
|
|
|
||
|
|
<div class="credentials">
|
||
|
|
<p>Default email: <strong>operator@example.com</strong></p>
|
||
|
|
<p>Default password: <strong>changeme</strong></p>
|
||
|
|
</div>
|
||
|
|
</article>
|
||
|
|
</section>
|
||
|
|
{:else}
|
||
|
|
<section class="signed-in-banner">
|
||
|
|
<div>
|
||
|
|
<p class="eyebrow">Active operator</p>
|
||
|
|
<h1>Welcome back, {$operatorSession.name}.</h1>
|
||
|
|
<p>Manage raw materials first, then review Mix Master and product pricing before publishing changes.</p>
|
||
|
|
</div>
|
||
|
|
<a class="primary-link" href="/raw-materials">Open raw material manager</a>
|
||
|
|
</section>
|
||
|
|
{/if}
|
||
|
|
|
||
|
|
<section class="metric-row">
|
||
|
|
{#each [
|
||
|
|
{ label: 'Raw materials', value: data.rawMaterials.length, hint: 'Maintain live input costs' },
|
||
|
|
{ label: 'Mixes', value: data.mixes.length, hint: 'Recipes recalculated from inputs' },
|
||
|
|
{ label: 'Products', value: data.productCosts.length, hint: 'Delivered pricing outputs' },
|
||
|
|
{ label: 'Warnings', value: data.dataQuality.length, hint: 'Items needing review' }
|
||
|
|
] as card}
|
||
|
|
<article class="metric-card">
|
||
|
|
<p>{card.label}</p>
|
||
|
|
<strong>{card.value}</strong>
|
||
|
|
<span>{card.hint}</span>
|
||
|
|
</article>
|
||
|
|
{/each}
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="overview-grid">
|
||
|
|
<article class="surface-card">
|
||
|
|
<div class="panel-heading">
|
||
|
|
<div>
|
||
|
|
<p class="eyebrow">Latest input</p>
|
||
|
|
<h2>{featuredMaterial?.name ?? 'No materials loaded'}</h2>
|
||
|
|
</div>
|
||
|
|
<a href="/raw-materials">Manage inputs</a>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{#if featuredMaterial?.current_price}
|
||
|
|
<dl class="split-stats">
|
||
|
|
<div>
|
||
|
|
<dt>Market value</dt>
|
||
|
|
<dd>{currency(featuredMaterial.current_price.market_value)}</dd>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<dt>Waste</dt>
|
||
|
|
<dd>{(featuredMaterial.current_price.waste_percentage * 100).toFixed(1)}%</dd>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<dt>Cost per kg</dt>
|
||
|
|
<dd>{currency(featuredMaterial.current_price.cost_per_kg, 4)}</dd>
|
||
|
|
</div>
|
||
|
|
</dl>
|
||
|
|
{:else}
|
||
|
|
<p class="empty">No active price version is available for this material.</p>
|
||
|
|
{/if}
|
||
|
|
</article>
|
||
|
|
|
||
|
|
<article class="surface-card">
|
||
|
|
<div class="panel-heading">
|
||
|
|
<div>
|
||
|
|
<p class="eyebrow">Mix master</p>
|
||
|
|
<h2>{featuredMix?.name ?? 'No mixes loaded'}</h2>
|
||
|
|
</div>
|
||
|
|
<a href="/mixes">Review mixes</a>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{#if featuredMix}
|
||
|
|
<dl class="split-stats">
|
||
|
|
<div>
|
||
|
|
<dt>Client</dt>
|
||
|
|
<dd>{featuredMix.client_name}</dd>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<dt>Total mix cost</dt>
|
||
|
|
<dd>{currency(featuredMix.total_mix_cost)}</dd>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<dt>Cost per kg</dt>
|
||
|
|
<dd>{currency(featuredMix.mix_cost_per_kg, 4)}</dd>
|
||
|
|
</div>
|
||
|
|
</dl>
|
||
|
|
{/if}
|
||
|
|
</article>
|
||
|
|
|
||
|
|
<article class="surface-card">
|
||
|
|
<div class="panel-heading">
|
||
|
|
<div>
|
||
|
|
<p class="eyebrow">Delivered output</p>
|
||
|
|
<h2>{featuredProduct?.product_name ?? 'No product costs loaded'}</h2>
|
||
|
|
</div>
|
||
|
|
<a href="/products">Review products</a>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{#if featuredProduct}
|
||
|
|
<dl class="split-stats">
|
||
|
|
<div>
|
||
|
|
<dt>Delivered</dt>
|
||
|
|
<dd>{currency(featuredProduct.finished_product_delivered)}</dd>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<dt>Distributor</dt>
|
||
|
|
<dd>{currency(featuredProduct.distributor_price)}</dd>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<dt>Wholesale</dt>
|
||
|
|
<dd>{currency(featuredProduct.wholesale_price)}</dd>
|
||
|
|
</div>
|
||
|
|
</dl>
|
||
|
|
{/if}
|
||
|
|
</article>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="detail-grid">
|
||
|
|
<article class="surface-card">
|
||
|
|
<div class="panel-heading">
|
||
|
|
<div>
|
||
|
|
<p class="eyebrow">Current cascade</p>
|
||
|
|
<h2>Raw material updates flow straight into Mix Master</h2>
|
||
|
|
</div>
|
||
|
|
<a href="/raw-materials">Edit materials</a>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Mix</th>
|
||
|
|
<th>Ingredients</th>
|
||
|
|
<th>Total Cost</th>
|
||
|
|
<th>Cost/Kg</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{#each data.mixes as mix}
|
||
|
|
<tr>
|
||
|
|
<td>{mix.name}</td>
|
||
|
|
<td>{mix.ingredients.length}</td>
|
||
|
|
<td>{currency(mix.total_mix_cost)}</td>
|
||
|
|
<td>{currency(mix.mix_cost_per_kg, 4)}</td>
|
||
|
|
</tr>
|
||
|
|
{/each}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</article>
|
||
|
|
|
||
|
|
<article class="surface-card">
|
||
|
|
<div class="panel-heading">
|
||
|
|
<div>
|
||
|
|
<p class="eyebrow">Output pricing</p>
|
||
|
|
<h2>Finished products</h2>
|
||
|
|
</div>
|
||
|
|
<a href="/products">Open products</a>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Product</th>
|
||
|
|
<th>Delivered</th>
|
||
|
|
<th>Distributor</th>
|
||
|
|
<th>Wholesale</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{#each data.productCosts as row}
|
||
|
|
<tr>
|
||
|
|
<td>{row.product_name}</td>
|
||
|
|
<td>{currency(row.finished_product_delivered)}</td>
|
||
|
|
<td>{currency(row.distributor_price)}</td>
|
||
|
|
<td>{currency(row.wholesale_price)}</td>
|
||
|
|
</tr>
|
||
|
|
{/each}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</article>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
h1,
|
||
|
|
h2,
|
||
|
|
p,
|
||
|
|
dl,
|
||
|
|
dd {
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
a {
|
||
|
|
color: var(--brand);
|
||
|
|
text-decoration: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.eyebrow {
|
||
|
|
color: var(--muted);
|
||
|
|
font-size: 0.78rem;
|
||
|
|
letter-spacing: 0.1em;
|
||
|
|
text-transform: uppercase;
|
||
|
|
}
|
||
|
|
|
||
|
|
.hero-grid,
|
||
|
|
.overview-grid,
|
||
|
|
.detail-grid {
|
||
|
|
display: grid;
|
||
|
|
gap: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.hero-grid {
|
||
|
|
grid-template-columns: minmax(0, 1.6fr) minmax(20rem, 0.9fr);
|
||
|
|
margin-bottom: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.overview-grid {
|
||
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
|
|
margin-bottom: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.detail-grid {
|
||
|
|
grid-template-columns: 1.3fr 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
.hero-panel,
|
||
|
|
.surface-card,
|
||
|
|
.metric-card,
|
||
|
|
.signed-in-banner {
|
||
|
|
background: rgba(255, 250, 241, 0.82);
|
||
|
|
border: 1px solid var(--line);
|
||
|
|
border-radius: 1.5rem;
|
||
|
|
box-shadow: var(--shadow);
|
||
|
|
}
|
||
|
|
|
||
|
|
.intro,
|
||
|
|
.login-panel,
|
||
|
|
.surface-card,
|
||
|
|
.signed-in-banner {
|
||
|
|
padding: 1.5rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.intro {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 1.4rem;
|
||
|
|
background:
|
||
|
|
linear-gradient(135deg, rgba(90, 45, 24, 0.95), rgba(143, 79, 31, 0.88)),
|
||
|
|
linear-gradient(180deg, rgba(217, 164, 65, 0.18), transparent);
|
||
|
|
color: #fff7ef;
|
||
|
|
}
|
||
|
|
|
||
|
|
.intro .eyebrow,
|
||
|
|
.intro .lede,
|
||
|
|
.intro .workflow p {
|
||
|
|
color: rgba(255, 247, 239, 0.8);
|
||
|
|
}
|
||
|
|
|
||
|
|
.intro h1 {
|
||
|
|
font-size: clamp(2rem, 4vw, 3.6rem);
|
||
|
|
max-width: 11ch;
|
||
|
|
line-height: 0.95;
|
||
|
|
}
|
||
|
|
|
||
|
|
.lede {
|
||
|
|
max-width: 52rem;
|
||
|
|
line-height: 1.6;
|
||
|
|
}
|
||
|
|
|
||
|
|
.workflow {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
|
|
gap: 0.9rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.workflow article {
|
||
|
|
padding: 1rem;
|
||
|
|
border-radius: 1rem;
|
||
|
|
background: rgba(255, 247, 239, 0.08);
|
||
|
|
border: 1px solid rgba(255, 247, 239, 0.12);
|
||
|
|
}
|
||
|
|
|
||
|
|
.workflow span {
|
||
|
|
display: inline-flex;
|
||
|
|
width: 2rem;
|
||
|
|
height: 2rem;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
border-radius: 999px;
|
||
|
|
background: rgba(217, 164, 65, 0.18);
|
||
|
|
margin-bottom: 0.8rem;
|
||
|
|
font-weight: 700;
|
||
|
|
}
|
||
|
|
|
||
|
|
.workflow h2 {
|
||
|
|
margin-bottom: 0.45rem;
|
||
|
|
font-size: 1.1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel-heading,
|
||
|
|
.signed-in-banner {
|
||
|
|
display: flex;
|
||
|
|
align-items: start;
|
||
|
|
justify-content: space-between;
|
||
|
|
gap: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.login-panel {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 1.25rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.badge,
|
||
|
|
.primary-link {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
border-radius: 999px;
|
||
|
|
padding: 0.7rem 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.badge {
|
||
|
|
background: rgba(143, 79, 31, 0.08);
|
||
|
|
color: var(--brand-deep);
|
||
|
|
font-size: 0.82rem;
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
.primary-link {
|
||
|
|
background: var(--brand-deep);
|
||
|
|
color: #fff7ef;
|
||
|
|
box-shadow: var(--shadow);
|
||
|
|
}
|
||
|
|
|
||
|
|
.login-form {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.9rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.login-form label {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.35rem;
|
||
|
|
color: var(--muted);
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
.login-form input {
|
||
|
|
width: 100%;
|
||
|
|
padding: 0.9rem 1rem;
|
||
|
|
border-radius: 1rem;
|
||
|
|
border: 1px solid rgba(90, 45, 24, 0.16);
|
||
|
|
background: rgba(255, 255, 255, 0.8);
|
||
|
|
font: inherit;
|
||
|
|
}
|
||
|
|
|
||
|
|
.login-form button {
|
||
|
|
padding: 0.95rem 1.1rem;
|
||
|
|
border: none;
|
||
|
|
border-radius: 1rem;
|
||
|
|
background: linear-gradient(135deg, var(--brand-deep), var(--brand));
|
||
|
|
color: #fff7ef;
|
||
|
|
font: inherit;
|
||
|
|
font-weight: 700;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
.login-form button:disabled {
|
||
|
|
opacity: 0.7;
|
||
|
|
cursor: wait;
|
||
|
|
}
|
||
|
|
|
||
|
|
.form-error {
|
||
|
|
color: #a3301d;
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
.credentials {
|
||
|
|
padding: 1rem;
|
||
|
|
border-radius: 1rem;
|
||
|
|
background: rgba(143, 79, 31, 0.06);
|
||
|
|
color: var(--muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.credentials strong {
|
||
|
|
color: var(--ink);
|
||
|
|
}
|
||
|
|
|
||
|
|
.signed-in-banner {
|
||
|
|
margin-bottom: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.signed-in-banner p:last-child {
|
||
|
|
color: var(--muted);
|
||
|
|
margin-top: 0.45rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.metric-row {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
|
|
gap: 1rem;
|
||
|
|
margin-bottom: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.metric-card {
|
||
|
|
padding: 1.1rem 1.2rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.metric-card p,
|
||
|
|
.metric-card span,
|
||
|
|
.empty,
|
||
|
|
dt {
|
||
|
|
color: var(--muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.metric-card strong {
|
||
|
|
display: block;
|
||
|
|
margin: 0.35rem 0;
|
||
|
|
font-size: 2rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.split-stats {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
|
|
gap: 1rem;
|
||
|
|
margin-top: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
dt {
|
||
|
|
font-size: 0.78rem;
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.08em;
|
||
|
|
}
|
||
|
|
|
||
|
|
dd {
|
||
|
|
margin-top: 0.2rem;
|
||
|
|
font-size: 1.05rem;
|
||
|
|
font-weight: 700;
|
||
|
|
}
|
||
|
|
|
||
|
|
table {
|
||
|
|
width: 100%;
|
||
|
|
border-collapse: collapse;
|
||
|
|
margin-top: 1rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
th,
|
||
|
|
td {
|
||
|
|
padding: 0.85rem 0.4rem;
|
||
|
|
text-align: left;
|
||
|
|
border-bottom: 1px solid var(--line);
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 1100px) {
|
||
|
|
.hero-grid,
|
||
|
|
.overview-grid,
|
||
|
|
.detail-grid,
|
||
|
|
.metric-row {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 760px) {
|
||
|
|
.workflow,
|
||
|
|
.split-stats {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel-heading,
|
||
|
|
.signed-in-banner {
|
||
|
|
flex-direction: column;
|
||
|
|
align-items: start;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</style>
|