tweaks
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>© {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>© {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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user