This commit is contained in:
2026-05-31 20:19:44 +12:00
parent 2f2466ecac
commit 84792c0947
59 changed files with 5412 additions and 898 deletions
+5 -14
View File
@@ -42,30 +42,21 @@
<div class="error-actions">
<a class="primary-link" href="/">Return to Workspace</a>
<a class="secondary-link" href="/mix-calculator/new">Open Mix Calculator</a>
<a class="secondary-link" href="/mix-calculator">Open Mix Calculator</a>
</div>
</div>
</section>
<style>
:global(body) {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(214, 234, 221, 0.86), transparent 36%),
linear-gradient(180deg, #f4f7f2 0%, #eef4ee 100%);
color: #1d3528;
font-family:
"Segoe UI",
system-ui,
sans-serif;
}
.error-stage {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
background:
radial-gradient(circle at top left, rgba(214, 234, 221, 0.86), transparent 36%),
linear-gradient(180deg, #f4f7f2 0%, #eef4ee 100%);
color: #1d3528;
}
.error-card {
+21
View File
@@ -1,4 +1,8 @@
<script lang="ts">
import '@fontsource/inter/latin-400.css';
import '@fontsource/inter/latin-500.css';
import '@fontsource/inter/latin-600.css';
import '@fontsource/inter/latin-700.css';
import { beforeNavigate, afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import AdminShell from '$lib/components/AdminShell.svelte';
@@ -46,3 +50,20 @@
{/if}
<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>
+56 -59
View File
@@ -42,7 +42,6 @@
let loginFocusArmed = $state(true);
const currentYear = new Date().getFullYear();
const appVersion = `v${packageInfo.version}`;
const releaseStage = 'Beta';
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep'];
@@ -353,10 +352,7 @@
<span class="powered-by-label">Powered by Lean 101</span>
</div>
<div class="auth-meta">
<span class="version-badge">
<span>{appVersion}</span>
<span class="release-pill">{releaseStage}</span>
</span>
<span class="version-badge">{appVersion}</span>
<span>&copy; {currentYear} Hunter Premium Produce</span>
</div>
</div>
@@ -375,13 +371,12 @@
</div>
<div class="auth-status-row">
<span class="auth-status-pill">Secure Workspace Access</span>
<span class="release-pill">{releaseStage}</span>
</div>
</div>
<div class="auth-copy">
<h2>Login</h2>
<p>Enter your username & password below:</p>
<h2>Welcome back</h2>
<p>Sign in with your email and password to continue.</p>
</div>
<form class="signin-form auth-form" onsubmit={handleLogin}>
@@ -422,10 +417,7 @@
<span class="powered-by-label">Powered by Lean 101</span>
</div>
<div class="auth-meta">
<span class="version-badge">
<span>{appVersion}</span>
<span class="release-pill">{releaseStage}</span>
</span>
<span class="version-badge">{appVersion}</span>
<span>&copy; {currentYear} Lean 101</span>
</div>
</div>
@@ -806,25 +798,23 @@
width: min(100%, 38rem);
display: grid;
gap: 1.35rem;
padding: 1.5rem;
border: 1px solid rgba(212, 226, 218, 0.95);
border-radius: 1.7rem;
background:
radial-gradient(circle at top left, rgba(115, 197, 146, 0.16), transparent 32%),
radial-gradient(circle at bottom right, rgba(33, 94, 60, 0.1), transparent 30%),
rgba(255, 255, 255, 0.96);
box-shadow: none;
backdrop-filter: blur(14px);
padding: 2.1rem 2rem 1.6rem;
border: 1px solid var(--color-border);
border-radius: 1.25rem;
background: var(--color-bg-surface);
overflow: hidden;
}
/* Crisp brand accent along the top edge, clipped to the card radius.
Replaces the old green radial glow, which read as a muddy shadow. */
.auth-card::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.42), transparent 35%),
linear-gradient(180deg, transparent, rgba(238, 248, 242, 0.55));
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--color-brand);
pointer-events: none;
}
@@ -913,21 +903,6 @@
flex-wrap: wrap;
}
.release-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.28rem 0.62rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, transparent);
border-radius: 999px;
background: var(--color-brand-tint);
color: var(--color-success);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.auth-copy {
display: grid;
gap: 0.55rem;
@@ -994,20 +969,25 @@
}
.auth-form input {
padding: 1rem 1.05rem;
border: 1px solid #d6e3db;
border-radius: 1rem;
background: rgba(248, 251, 249, 0.94);
padding: 0.95rem 1rem;
border: 1px solid var(--color-border);
border-radius: 0.7rem;
background: var(--color-bg-app);
color: var(--color-text-primary);
transition:
border-color 160ms ease,
box-shadow 160ms ease,
background-color 160ms ease;
}
.auth-form input::placeholder {
color: var(--color-text-muted);
}
.auth-form input:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 0.24rem color-mix(in srgb, var(--color-brand) 12%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 22%, transparent);
background: #fff;
}
@@ -1021,6 +1001,26 @@
width: 100%;
min-height: 3.35rem;
margin-top: 0.2rem;
font-size: 1.02rem;
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.auth-submit:hover:not(:disabled) {
background: #126a33;
}
.auth-submit:active:not(:disabled) {
background: #0f5a2b;
}
.auth-submit:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
outline-offset: 2px;
}
.auth-submit:disabled {
opacity: 0.7;
cursor: progress;
}
.auth-footer {
@@ -1221,23 +1221,20 @@
.signin-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
width: min(100%, 38rem);
}
.signin-form input {
grid-template-columns: 1fr;
gap: 0.95rem;
width: 100%;
padding: 0.9rem 0.95rem;
border: 1px solid var(--line-strong);
border-radius: 0.95rem;
background: var(--panel-soft);
color: var(--text);
}
.login-error {
color: #a03737;
margin: 0;
padding: 0.75rem 0.95rem;
border: 1px solid rgba(160, 55, 55, 0.3);
border-radius: 0.7rem;
background: #fdf2f2;
color: #8a1622;
font-weight: 600;
font-size: 0.95rem;
}
.focus-card {
@@ -1905,8 +1902,8 @@
}
.auth-card {
padding: 1.15rem;
border-radius: 1.35rem;
padding: 1.5rem 1.15rem 1.15rem;
border-radius: 1.1rem;
}
.auth-header,
+2 -2
View File
@@ -118,10 +118,10 @@ describe('route loaders use the SvelteKit fetch argument', () => {
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the mix calculator history loader', async () => {
it('passes fetch through the mix calculator loader', async () => {
await mixCalculatorLoad({ fetch: fetcher } as never);
expect(apiMocks.mixCalculatorSessions).toHaveBeenCalledWith(fetcher);
expect(apiMocks.mixCalculatorOptions).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the new mix calculator loader', async () => {
+37 -15
View File
@@ -1,9 +1,13 @@
<script lang="ts">
import { clientSession, hasModuleAccess } from '$lib/session';
import type { MixCalculatorSession } from '$lib/types';
import { featureFlags } from '$lib/features';
import MixCalculatorEditor from '$lib/components/mix-calculator/MixCalculatorEditor.svelte';
import type { MixCalculatorOptions, MixCalculatorSession } from '$lib/types';
let { data }: { data: { sessions: MixCalculatorSession[] } } = $props();
let { data }: { data: { sessions?: MixCalculatorSession[]; options?: MixCalculatorOptions } } =
$props();
const sessions = $derived(data.sessions ?? []);
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
function formatDate(value: string) {
@@ -18,6 +22,9 @@
}
</script>
{#if !featureFlags.mixCalculatorSessionHistory}
<MixCalculatorEditor options={data.options} />
{:else}
{#if canEdit}
<section class="page-actions">
<a class="primary-button" href="/mix-calculator/new">New mix session</a>
@@ -27,17 +34,17 @@
<section class="metric-row">
<article class="metric-card">
<span>Saved Sessions</span>
<strong>{data.sessions.length}</strong>
<strong>{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>
<strong>{formatNumber(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>
<strong>{sessions.filter((session) => session.warnings.length).length}</strong>
<p>Fractional bag outputs need review</p>
</article>
</section>
@@ -50,7 +57,7 @@
</div>
</div>
{#if data.sessions.length}
{#if sessions.length}
<div class="table-wrap">
<table>
<thead>
@@ -65,7 +72,7 @@
</tr>
</thead>
<tbody>
{#each data.sessions as session}
{#each sessions as session}
<tr>
<td data-label="Session">
<strong>{session.session_number}</strong>
@@ -102,6 +109,7 @@
</div>
{/if}
</section>
{/if}
<style>
h2,
@@ -140,10 +148,24 @@
align-items: center;
justify-content: center;
padding: 0.78rem 0.96rem;
border-radius: 0.9rem;
border-radius: 0.6rem;
background: var(--color-brand);
color: #fff;
font-weight: 600;
transition: background-color 160ms ease;
}
.primary-button:hover {
background: #126a33;
}
.primary-button:active {
background: #0f5a2b;
}
.primary-button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
outline-offset: 2px;
}
.metric-row {
@@ -155,7 +177,7 @@
.metric-card,
.table-card {
border: 1px solid var(--line);
border-radius: 1.3rem;
border-radius: 0.8rem;
background: var(--panel);
box-shadow: var(--shadow);
}
@@ -222,12 +244,12 @@
tbody td:first-child {
border-left: 1px solid var(--line);
border-radius: 1rem 0 0 1rem;
border-radius: 0.65rem 0 0 0.65rem;
}
tbody td:last-child {
border-right: 1px solid var(--line);
border-radius: 0 1rem 1rem 0;
border-radius: 0 0.65rem 0.65rem 0;
}
.row-actions {
@@ -245,8 +267,8 @@
margin-left: 0.55rem;
padding: 0.25rem 0.5rem;
border-radius: 999px;
background: #fff6e6;
color: #8b5b1e;
background: #fdf6e9;
color: #8a5a00;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
@@ -257,7 +279,7 @@
display: grid;
gap: 0.2rem;
padding: 1rem;
border-radius: 1rem;
border-radius: 0.65rem;
background: var(--panel-soft);
}
@@ -299,7 +321,7 @@
tbody tr {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: 1rem;
border-radius: 0.65rem;
background: var(--panel-soft);
}
+28 -9
View File
@@ -2,17 +2,35 @@ import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { featureFlags } from '$lib/features';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canOpenMixCalculator, getWorkspaceHomeHref } from '$lib/workspace-access';
import { canCreateMixSession, canOpenMixCalculator, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
// Single-page mode (session history disabled): this route IS the calculator.
if (!featureFlags.mixCalculatorSessionHistory) {
throw redirect(307, '/mix-calculator/new');
if (!hasStoredClientSession()) {
return { options: { clients: [], products: [] } };
}
const session = getStoredClientSession();
if (!canCreateMixSession(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
return {
options:
hasModuleAccess(session, 'mix_calculator', 'edit') || session?.role === 'internal'
? await api.mixCalculatorOptions(fetch)
: { clients: [], products: [] }
};
} catch {
return { options: { clients: [], products: [] } };
}
}
// History mode: list saved sessions.
if (!hasStoredClientSession()) {
return {
sessions: []
};
return { sessions: [] };
}
const session = getStoredClientSession();
@@ -22,11 +40,12 @@ export async function load({ fetch }) {
try {
return {
sessions: hasModuleAccess(session, 'mix_calculator') || session?.role === 'internal' ? await api.mixCalculatorSessions(fetch) : []
sessions:
hasModuleAccess(session, 'mix_calculator') || session?.role === 'internal'
? await api.mixCalculatorSessions(fetch)
: []
};
} catch {
return {
sessions: []
};
return { sessions: [] };
}
}
@@ -15,7 +15,7 @@
{#if featureFlags.mixCalculatorSessionHistory}
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
{:else}
<a class="secondary-button" href="/mix-calculator/new">New mix session</a>
<a class="secondary-button" href="/mix-calculator">New mix session</a>
{/if}
</section>
{/if}
@@ -38,7 +38,7 @@
max-width: 42rem;
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
border-radius: 0.8rem;
background: var(--panel);
box-shadow: var(--shadow);
}
@@ -58,7 +58,7 @@
margin-top: 1rem;
padding: 0.78rem 0.92rem;
border: 1px solid var(--line-strong);
border-radius: 0.88rem;
border-radius: 0.6rem;
background: #fff;
color: #304038;
font-weight: 600;
@@ -15,7 +15,7 @@
{#if featureFlags.mixCalculatorSessionHistory}
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
{:else}
<a class="secondary-button" href="/mix-calculator/new">New mix session</a>
<a class="secondary-button" href="/mix-calculator">New mix session</a>
{/if}
</section>
{/if}
+1 -1
View File
@@ -31,7 +31,7 @@ describe('root route access', () => {
sessionMocks.hasModuleAccess.mockReturnValue(false);
expect(() => load({ fetch: vi.fn() as typeof fetch })).toThrow(
expect.objectContaining({ status: 307, location: '/mix-calculator/new' })
expect.objectContaining({ status: 307, location: '/mix-calculator' })
);
});
@@ -30,6 +30,6 @@ describe('settings route access', () => {
sessionMocks.hasPermission.mockImplementation((session, key) => session?.permissions?.includes(key) ?? false);
sessionMocks.hasModuleAccess.mockReturnValue(false);
expect(() => load()).toThrow(expect.objectContaining({ status: 307, location: '/mix-calculator/new' }));
expect(() => load()).toThrow(expect.objectContaining({ status: 307, location: '/mix-calculator' }));
});
});
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenThroughput, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { entries: [], products: [] };
}
const session = getStoredClientSession();
if (!canOpenThroughput(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
// Default view shows the last 30 days; "Find past entries" surfaces older ones.
const recentFrom = new Date();
recentFrom.setDate(recentFrom.getDate() - 30);
const dateFrom = recentFrom.toISOString().slice(0, 10);
try {
const [entries, products] = await Promise.all([
api.throughputEntries({ date_from: dateFrom, limit: 200 }, fetch),
api.throughputProducts(fetch)
]);
return { entries, products };
} catch {
return { entries: [], products: [] };
}
}
@@ -0,0 +1,389 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import type {
ThroughputEntryCreateInput,
ThroughputProduct,
ThroughputQuantityType
} from '$lib/types';
import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte';
let { data } = $props<{ data: { products: ThroughputProduct[] } }>();
const products = $derived(data.products ?? []);
const today = new Date().toISOString().slice(0, 10);
let productionDate = $state(today);
let productId = $state<string>('');
let bagSize = $state<string>('');
let quantity = $state<string>('');
let quantityType = $state<ThroughputQuantityType>('bags');
let scalesChecked = $state(true);
let labelCorrect = $state(true);
let bagSealed = $state(true);
let palletGood = $state(true);
let sampleBoxNo = $state('');
let tw1 = $state('');
let tw2 = $state('');
let tw3 = $state('');
let tw4 = $state('');
let tw5 = $state('');
let staffName = $state('');
let notes = $state('');
let saving = $state(false);
let successMessage = $state('');
let errorMessage = $state('');
const selectedProduct = $derived(
productId ? products.find((p: ThroughputProduct) => String(p.id) === productId) ?? null : null
);
$effect(() => {
if (selectedProduct) {
if (!bagSize && selectedProduct.default_bag_size != null) {
bagSize = String(selectedProduct.default_bag_size);
}
if (selectedProduct.is_bulka_default) {
quantityType = 'kg';
}
}
});
const qaWarning = $derived(!(scalesChecked && labelCorrect && bagSealed && palletGood));
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 = '';
quantity = '';
quantityType = 'bags';
scalesChecked = true;
labelCorrect = true;
bagSealed = true;
palletGood = true;
sampleBoxNo = '';
tw1 = '';
tw2 = '';
tw3 = '';
tw4 = '';
tw5 = '';
notes = '';
}
function buildPayload(): ThroughputEntryCreateInput | null {
const qty = toNum(quantity);
if (qty === null || qty < 0) {
errorMessage = 'Quantity is required and must be 0 or greater.';
return null;
}
if (!productionDate) {
errorMessage = 'Date is required.';
return null;
}
if (!productId) {
errorMessage = 'Product is required.';
return null;
}
const bag = toNum(bagSize);
if (quantityType === 'bags' && (bag === null || bag <= 0)) {
errorMessage = 'Bag size is required when quantity type is "bags".';
return null;
}
return {
production_date: productionDate,
product_id: Number(productId),
product_name_snapshot: selectedProduct?.name ?? '',
bag_size: bag,
scales_checked: scalesChecked,
label_correct: labelCorrect,
bag_sealed: bagSealed,
pallet_good_condition: palletGood,
sample_box_no: sampleBoxNo.trim() || null,
test_weight_1: toNum(tw1),
test_weight_2: toNum(tw2),
test_weight_3: toNum(tw3),
test_weight_4: toNum(tw4),
test_weight_5: toNum(tw5),
quantity: qty,
quantity_type: quantityType,
staff_name: staffName.trim() || null,
notes: notes.trim() || null
};
}
async function submit(mode: 'save' | 'save-and-add') {
errorMessage = '';
successMessage = '';
const payload = buildPayload();
if (!payload) return;
saving = true;
try {
await api.createThroughputEntry(payload);
if (mode === 'save') {
await goto('/throughput');
return;
}
successMessage = 'Entry saved. Ready for the next one.';
resetExceptDateAndStaff();
} catch (err) {
errorMessage = err instanceof Error ? err.message : 'Failed to save entry';
} finally {
saving = false;
}
}
</script>
<section class="add-entry">
<header class="page-header">
<a class="back-link" href="/throughput"><ArrowLeft size={16} /> Back to log</a>
<h1>Add Throughput Entry</h1>
</header>
{#if successMessage}
<div class="banner banner-ok"><CheckCircle2 size={16} /> {successMessage}</div>
{/if}
{#if errorMessage}
<div class="banner banner-error"><AlertTriangle size={16} /> {errorMessage}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); submit('save'); }}>
<div class="grid">
<label>
<span>Date *</span>
<input type="date" bind:value={productionDate} required />
</label>
<label>
<span>Product *</span>
<select bind:value={productId} required>
<option value="">Select product…</option>
{#each products as p (p.id)}
<option value={String(p.id)}>{p.name}{p.item_id ? ` · ${p.item_id}` : ''}</option>
{/each}
</select>
</label>
<label>
<span>Bag size (kg)</span>
<input type="number" min="0" step="0.01" bind:value={bagSize} placeholder="e.g. 20" />
</label>
<label>
<span>Quantity *</span>
<input type="number" min="0" step="0.01" bind:value={quantity} required />
</label>
<label>
<span>Quantity type *</span>
<select bind:value={quantityType}>
<option value="bags">Bags</option>
<option value="kg">Kilograms (bulka / bulk)</option>
</select>
</label>
<label>
<span>Sample box no.</span>
<input type="text" bind:value={sampleBoxNo} />
</label>
</div>
<fieldset class="qa">
<legend>QA checklist</legend>
<label class="check"><input type="checkbox" bind:checked={scalesChecked} /> Scales checked</label>
<label class="check"><input type="checkbox" bind:checked={labelCorrect} /> Label correct</label>
<label class="check"><input type="checkbox" bind:checked={bagSealed} /> Bag sealed</label>
<label class="check"><input type="checkbox" bind:checked={palletGood} /> Pallet in good condition</label>
{#if qaWarning}
<p class="qa-warning"><AlertTriangle size={14} /> One or more QA checks failed — this entry will be flagged.</p>
{/if}
</fieldset>
<fieldset class="weights">
<legend>Test weights (optional, RCP 001)</legend>
<div class="weight-grid">
<label><span>1</span><input type="number" step="0.01" bind:value={tw1} /></label>
<label><span>2</span><input type="number" step="0.01" bind:value={tw2} /></label>
<label><span>3</span><input type="number" step="0.01" bind:value={tw3} /></label>
<label><span>4</span><input type="number" step="0.01" bind:value={tw4} /></label>
<label><span>5</span><input type="number" step="0.01" bind:value={tw5} /></label>
</div>
</fieldset>
<div class="grid">
<label>
<span>Staff name</span>
<input type="text" bind:value={staffName} placeholder="e.g. Jake" />
</label>
<label class="full">
<span>Notes</span>
<textarea rows="2" bind:value={notes}></textarea>
</label>
</div>
<div class="actions">
<button type="submit" class="primary-button" disabled={saving}>Save</button>
<button type="button" class="secondary-button" disabled={saving} onclick={() => submit('save-and-add')}>
Save and add another
</button>
<a href="/throughput" class="ghost-button">Cancel</a>
</div>
</form>
</section>
<style>
.add-entry {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1rem 0;
max-width: 980px;
}
.page-header {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--text-muted, #6b7280);
font-size: 0.85rem;
text-decoration: none;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.65rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
}
label.full {
grid-column: 1 / -1;
}
input,
select,
textarea {
padding: 0.5rem 0.65rem;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.4rem;
font: inherit;
}
fieldset {
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
legend {
padding: 0 0.4rem;
font-weight: 600;
font-size: 0.85rem;
}
.qa {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.4rem 1rem;
}
.check {
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
.qa-warning {
grid-column: 1 / -1;
margin: 0;
color: #92400e;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
}
.weight-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
}
.weight-grid label {
flex-direction: column;
gap: 0.25rem;
}
.weight-grid label span {
font-size: 0.75rem;
color: var(--text-muted, #6b7280);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.primary-button {
padding: 0.6rem 1rem;
background: var(--accent, #1f2937);
color: white;
border-radius: 0.5rem;
border: 0;
font-weight: 600;
cursor: pointer;
}
.secondary-button {
padding: 0.6rem 1rem;
background: white;
border: 1px solid var(--border, #d1d5db);
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.ghost-button {
padding: 0.6rem 1rem;
border-radius: 0.5rem;
text-decoration: none;
color: var(--text-muted, #6b7280);
align-self: center;
}
.banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.85rem;
border-radius: 0.5rem;
}
.banner-ok {
background: #ecfdf5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.banner-error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
</style>
@@ -0,0 +1,22 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canEditThroughput, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { products: [] };
}
const session = getStoredClientSession();
if (!canEditThroughput(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const products = await api.throughputProducts(fetch);
return { products };
} catch {
return { products: [] };
}
}