tweaks
This commit is contained in:
Generated
+12
-2
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "1.5.6",
|
||||
"version": "0.1.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hunter-app",
|
||||
"version": "1.5.6",
|
||||
"version": "0.1.8",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"lucide-svelte": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -54,6 +55,15 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "1.5.6",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -19,6 +19,7 @@
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"lucide-svelte": "^1.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
+49
-3
@@ -33,7 +33,13 @@ import type {
|
||||
RawMaterial,
|
||||
RawMaterialCreateInput,
|
||||
RawMaterialPriceCreateInput,
|
||||
Scenario
|
||||
Scenario,
|
||||
ThroughputEntry,
|
||||
ThroughputEntryCreateInput,
|
||||
ThroughputEntryListParams,
|
||||
ThroughputProduct,
|
||||
ThroughputProductCreateInput,
|
||||
ThroughputProductUpdateInput
|
||||
} from '$lib/types';
|
||||
import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
|
||||
|
||||
@@ -248,12 +254,18 @@ async function request<T>(
|
||||
|
||||
async function requestBlob(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
auth: AuthMode = 'none',
|
||||
fetcher: ApiFetch = fetch
|
||||
): Promise<Blob> {
|
||||
try {
|
||||
const response = await fetcher(resolveRequestUrl(path, fetcher), {
|
||||
credentials: 'include'
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers ?? {})
|
||||
},
|
||||
credentials: 'include',
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -284,12 +296,17 @@ export const api = {
|
||||
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
|
||||
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
|
||||
mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) =>
|
||||
requestBlob(`/api/mix-calculator/${sessionId}/pdf`, 'client', fetcher),
|
||||
requestBlob(`/api/mix-calculator/${sessionId}/pdf`, {}, 'client', fetcher),
|
||||
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
|
||||
request<MixCalculatorPreview>('/api/mix-calculator/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
previewMixCalculatorPdf: (payload: MixCalculatorCreateInput) =>
|
||||
requestBlob('/api/mix-calculator/preview/pdf', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
createMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
|
||||
request<MixCalculatorSession>('/api/mix-calculator', {
|
||||
method: 'POST',
|
||||
@@ -304,6 +321,35 @@ export const api = {
|
||||
productCosts: (fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
|
||||
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
|
||||
throughputProducts: (fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<ThroughputProduct[]>('/api/throughput/products', [], 'client', fetcher),
|
||||
throughputEntries: (params?: ThroughputEntryListParams, fetcher?: ApiFetch) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.date_from) search.set('date_from', params.date_from);
|
||||
if (params?.date_to) search.set('date_to', params.date_to);
|
||||
if (params?.product_id != null) search.set('product_id', String(params.product_id));
|
||||
if (params?.staff_name) search.set('staff_name', params.staff_name);
|
||||
if (params?.quantity_type) search.set('quantity_type', params.quantity_type);
|
||||
if (params?.limit) search.set('limit', String(params.limit));
|
||||
const qs = search.toString();
|
||||
const path = qs ? `/api/throughput/entries?${qs}` : '/api/throughput/entries';
|
||||
return cachedFetchJson<ThroughputEntry[]>(path, [], 'client', fetcher);
|
||||
},
|
||||
createThroughputEntry: (payload: ThroughputEntryCreateInput) =>
|
||||
request<ThroughputEntry>('/api/throughput/entries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
createThroughputProduct: (payload: ThroughputProductCreateInput) =>
|
||||
request<ThroughputProduct>('/api/throughput/products', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
updateThroughputProduct: (productId: number, payload: ThroughputProductUpdateInput) =>
|
||||
request<ThroughputProduct>(`/api/throughput/products/${productId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
|
||||
clientAccessExport: (fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
canOpenReporting as sessionCanOpenReporting,
|
||||
canOpenScenarios as sessionCanOpenScenarios,
|
||||
canOpenSettings as sessionCanOpenSettings,
|
||||
canOpenThroughput as sessionCanOpenThroughput,
|
||||
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
|
||||
getWorkspaceRole,
|
||||
getWorkspaceHomeHref as sessionWorkspaceHomeHref,
|
||||
@@ -36,6 +37,7 @@
|
||||
mixCalculatorItem,
|
||||
pageTitle,
|
||||
reportingItem,
|
||||
throughputItem,
|
||||
type FooterLink,
|
||||
type SearchItem,
|
||||
type NavItem,
|
||||
@@ -67,7 +69,6 @@
|
||||
let seededSearchKey = $state<string | null>(null);
|
||||
let paletteInput: HTMLInputElement | null = $state(null);
|
||||
const appVersion = `v${packageInfo.version}`;
|
||||
const releaseStage = 'Beta';
|
||||
const currentYear = new Date().getFullYear();
|
||||
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
|
||||
const canOpenRawMaterials = $derived(sessionCanOpenRawMaterials($clientSession));
|
||||
@@ -85,7 +86,9 @@
|
||||
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
|
||||
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
|
||||
const shellTitle = $derived(routeGuardPending ? 'Loading Workspace' : pageTitle(page.url.pathname));
|
||||
const shellBreadcrumbs = $derived(routeGuardPending ? clientBreadcrumbs(workspaceHomeHref) : clientBreadcrumbs(page.url.pathname));
|
||||
const shellBreadcrumbs = $derived(
|
||||
routeGuardPending ? clientBreadcrumbs(workspaceHomeHref, $clientSession) : clientBreadcrumbs(page.url.pathname, $clientSession)
|
||||
);
|
||||
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
|
||||
const visibleWorkingDocumentItems = $derived(
|
||||
!$clientSession
|
||||
@@ -99,6 +102,8 @@
|
||||
})
|
||||
);
|
||||
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
|
||||
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
|
||||
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
|
||||
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
|
||||
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
|
||||
const workspaceRole = $derived(getWorkspaceRole($clientSession));
|
||||
@@ -125,7 +130,6 @@
|
||||
if (item.href === '/mixes') return canOpenMixMaster;
|
||||
if (item.href === '/mixes/new') return canCreateMixWorksheet;
|
||||
if (item.href === '/mix-calculator') return canOpenMixCalculator;
|
||||
if (item.href === '/mix-calculator/new') return canCreateMixSession;
|
||||
if (item.href === '/products') return canOpenProducts;
|
||||
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
|
||||
if (item.href === '/settings') return canOpenSettings;
|
||||
@@ -390,13 +394,13 @@
|
||||
primaryItems={[
|
||||
...(visibleDashboardItem ? [visibleDashboardItem] : []),
|
||||
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
|
||||
...(visibleThroughputItem ? [visibleThroughputItem] : []),
|
||||
...(visibleReportingItem ? [visibleReportingItem] : [])
|
||||
]}
|
||||
brandHref={workspaceHomeHref}
|
||||
workingDocumentItems={visibleWorkingDocumentItems}
|
||||
footerItems={visibleFooterLinks}
|
||||
{appVersion}
|
||||
{releaseStage}
|
||||
{currentYear}
|
||||
{canOpenSettings}
|
||||
onOpenSettings={openSettings}
|
||||
@@ -449,10 +453,10 @@
|
||||
<a href="/mixes/new">Create mix worksheet</a>
|
||||
{/if}
|
||||
{#if canOpenMixCalculator}
|
||||
<a href={featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'}>Open mix calculator</a>
|
||||
<a href="/mix-calculator">Open mix calculator</a>
|
||||
{/if}
|
||||
{#if canCreateMixSession}
|
||||
<a href="/mix-calculator/new">Create mix session</a>
|
||||
<a href="/mix-calculator">Create mix session</a>
|
||||
{/if}
|
||||
{#if canOpenProducts}
|
||||
<a href="/products">Review delivered pricing</a>
|
||||
@@ -540,6 +544,15 @@
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleThroughputItem}
|
||||
{@const Icon = visibleThroughputItem.icon}
|
||||
<a class:active={matchesRoute(visibleThroughputItem.href, page.url.pathname)} href={visibleThroughputItem.href} onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{visibleThroughputItem.label}</span>
|
||||
{#if visibleThroughputItem.badge}<span class="drawer-badge">{visibleThroughputItem.badge}</span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleReportingItem}
|
||||
{@const Icon = visibleReportingItem.icon}
|
||||
<a class:active={matchesRoute(visibleReportingItem.href, page.url.pathname)} href={visibleReportingItem.href} onclick={() => (navOpen = false)}>
|
||||
@@ -569,7 +582,7 @@
|
||||
</a>
|
||||
{/if}
|
||||
{#if canCreateMixSession}
|
||||
<a href="/mix-calculator/new" onclick={() => (navOpen = false)}>
|
||||
<a href="/mix-calculator" onclick={() => (navOpen = false)}>
|
||||
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
|
||||
<span>Create mix session</span>
|
||||
</a>
|
||||
@@ -659,8 +672,6 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
:global(:root) {
|
||||
/* ── Brand ──────────────────────────────────────────────── */
|
||||
--color-brand: #15803d;
|
||||
@@ -705,7 +716,7 @@
|
||||
min-height: 100%;
|
||||
background: var(--color-bg-app);
|
||||
color: var(--color-text-primary);
|
||||
font-family: Inter, "Segoe UI", sans-serif;
|
||||
font-family: "Inter", "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -714,7 +725,7 @@
|
||||
}
|
||||
|
||||
:global(h1, h2, h3, h4, h5, h6) {
|
||||
font-family: Inter, "Segoe UI", sans-serif;
|
||||
font-family: "Inter", "Segoe UI", sans-serif;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
@@ -734,6 +745,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: 252px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.signed-out-shell {
|
||||
@@ -954,6 +966,7 @@
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.workspace-label {
|
||||
@@ -1069,6 +1082,7 @@
|
||||
min-width: 0;
|
||||
padding: var(--content-padding);
|
||||
overflow: auto;
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.locked-card {
|
||||
@@ -1346,6 +1360,18 @@
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.drawer-badge {
|
||||
margin-left: auto;
|
||||
padding: 0.08rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: #fdf0d2;
|
||||
color: #8a5a00;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-group {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
let {
|
||||
session,
|
||||
generatedAt = null,
|
||||
showGeneratedStamp = true
|
||||
showGeneratedStamp = false
|
||||
}: {
|
||||
session: MixCalculatorPreview | MixCalculatorSession;
|
||||
generatedAt?: string | null;
|
||||
@@ -35,111 +35,66 @@
|
||||
|
||||
const sessionNumber = $derived(hasSessionNumber(session) ? session.session_number : null);
|
||||
const issuedAt = $derived(generatedAt ?? new Date().toISOString());
|
||||
const blendTotal = $derived(session.lines.reduce((sum, line) => sum + line.mix_percentage, 0));
|
||||
</script>
|
||||
|
||||
<article class="print-document">
|
||||
<header class="hero">
|
||||
<div class="hero-copy">
|
||||
<div class="hero-kicker">
|
||||
<span>Mix Calculator</span>
|
||||
{#if sessionNumber}
|
||||
<strong>{sessionNumber}</strong>
|
||||
{/if}
|
||||
</div>
|
||||
<header class="document-header">
|
||||
<img class="brand-mark" src="/logo-hsf.png" alt="Hunter Stock Feeds" />
|
||||
|
||||
<div class="title-block">
|
||||
<p class="eyebrow">Calculated Output</p>
|
||||
<h1>{session.product_name}</h1>
|
||||
<p>{session.client_name} · {session.mix_name}</p>
|
||||
<p class="subtitle">Snapshot of the scaled raw material requirements.</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-side">
|
||||
<div>
|
||||
<span>Mix date</span>
|
||||
<strong>{formatDate(session.mix_date)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Prepared by</span>
|
||||
<strong>{session.prepared_by_name}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong>{session.status}</strong>
|
||||
</div>
|
||||
<div class="session-block">
|
||||
{#if sessionNumber}
|
||||
<span>Session {sessionNumber}</span>
|
||||
{/if}
|
||||
{#if showGeneratedStamp}
|
||||
<span>Generated {formatTimestamp(issuedAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="summary-band" aria-label="Session summary">
|
||||
<article>
|
||||
<span>Batch size</span>
|
||||
<strong>{formatNumber(session.batch_size_kg, 2)}kg</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Total output</span>
|
||||
<strong>{formatNumber(session.total_kg, 2)}kg</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Bags</span>
|
||||
<strong>{formatNumber(session.total_bags, 2)}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Unit pack</span>
|
||||
<strong>{formatNumber(session.product_unit_size_kg, 2)}kg</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="detail-grid">
|
||||
<article class="detail-card">
|
||||
<span>Mix source</span>
|
||||
<strong>{session.mix_name}</strong>
|
||||
<p>Saved against {session.product_unit_of_measure} units.</p>
|
||||
</article>
|
||||
<article class="detail-card">
|
||||
<span>Composition</span>
|
||||
<strong>{formatNumber(blendTotal, 2)}%</strong>
|
||||
<p>{session.lines.length} raw material{session.lines.length === 1 ? '' : 's'} in the blend.</p>
|
||||
</article>
|
||||
{#if showGeneratedStamp}
|
||||
<article class="detail-card">
|
||||
<span>Generated</span>
|
||||
<strong>{formatTimestamp(issuedAt)}</strong>
|
||||
<p>Prepared for print or PDF export.</p>
|
||||
</article>
|
||||
{/if}
|
||||
<section class="meta-grid" aria-label="Print summary">
|
||||
<div><span>Total kg</span><strong>{formatNumber(session.total_kg, 2)}</strong><small>Scaled batch size</small></div>
|
||||
<div><span>Total bags</span><strong>{formatNumber(session.total_bags, 2)}</strong><small>{session.product_unit_of_measure}</small></div>
|
||||
<div><span>Prepared by</span><strong>{session.prepared_by_name}</strong><small>{formatDate(session.mix_date)}</small></div>
|
||||
<div><span>Client</span><strong>{session.client_name}</strong></div>
|
||||
<div><span>Product</span><strong>{session.product_name}</strong></div>
|
||||
<div><span>Mix source</span><strong>{session.mix_name}</strong></div>
|
||||
<div><span>Unit size</span><strong>{formatNumber(session.product_unit_size_kg, 2)}kg</strong></div>
|
||||
<div><span>Batch size</span><strong>{formatNumber(session.batch_size_kg, 2)}kg</strong></div>
|
||||
</section>
|
||||
|
||||
{#if session.notes}
|
||||
<section class="callout notes">
|
||||
<section class="inline-note">
|
||||
<span>Notes</span>
|
||||
<p>{session.notes}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if session.warnings.length}
|
||||
<section class="callout warning">
|
||||
<section class="inline-note warning">
|
||||
<span>Warnings</span>
|
||||
<ul>
|
||||
{#each session.warnings as warning}
|
||||
<li>{warning}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p>{session.warnings.join(' | ')}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="composition-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span>Required Raw Materials</span>
|
||||
<h2>Blend composition</h2>
|
||||
<span>Raw Material</span>
|
||||
</div>
|
||||
<p>{session.product_unit_of_measure} · {formatNumber(session.product_unit_size_kg, 2)}kg per unit</p>
|
||||
<p>Required kg</p>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raw material</th>
|
||||
<th>Mix %</th>
|
||||
<th>Required kg</th>
|
||||
<th>Unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -148,9 +103,7 @@
|
||||
<td>
|
||||
<strong>{line.raw_material_name}</strong>
|
||||
</td>
|
||||
<td>{formatNumber(line.mix_percentage, 2)}%</td>
|
||||
<td>{formatNumber(line.required_kg, 2)}kg</td>
|
||||
<td>{line.unit}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -162,232 +115,204 @@
|
||||
:global(:root) {
|
||||
--print-page-width: 210mm;
|
||||
--print-page-height: 297mm;
|
||||
--print-page-padding-x: 14mm;
|
||||
--print-page-padding-y: 15mm;
|
||||
--print-page-padding-x: 8mm;
|
||||
--print-page-padding-y: 14mm;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p,
|
||||
ul {
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.print-document {
|
||||
display: grid;
|
||||
gap: 1.4rem;
|
||||
gap: 0.6rem;
|
||||
width: min(100%, var(--print-page-width));
|
||||
min-height: var(--print-page-height);
|
||||
height: var(--print-page-height);
|
||||
margin: 0 auto;
|
||||
padding: var(--print-page-padding-y) var(--print-page-padding-x);
|
||||
border: 1px solid #dbe4de;
|
||||
border-radius: 0.8rem;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(21, 128, 61, 0.08), transparent 22rem),
|
||||
linear-gradient(180deg, #fff 0%, #fbfcfb 100%);
|
||||
color: #21312a;
|
||||
box-shadow:
|
||||
0 28px 48px rgba(21, 33, 26, 0.08),
|
||||
0 0 0 1px rgba(219, 228, 222, 0.55);
|
||||
border: 1px solid #000;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero {
|
||||
.document-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 17rem;
|
||||
gap: 1.25rem;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: start;
|
||||
padding-bottom: 1.3rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--line) 88%, #dce7df);
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #62736b;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
.brand-mark {
|
||||
width: 32mm;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter: grayscale(1) contrast(1.2);
|
||||
}
|
||||
|
||||
.hero-kicker strong {
|
||||
padding: 0.36rem 0.55rem;
|
||||
border: 1px solid #d5dfd9;
|
||||
border-radius: 999px;
|
||||
color: #214233;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
max-width: 11ch;
|
||||
font-size: clamp(2rem, 4vw, 3.3rem);
|
||||
line-height: 0.96;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.section-heading p,
|
||||
.detail-card p {
|
||||
color: #6b7a73;
|
||||
}
|
||||
|
||||
.hero-copy p {
|
||||
margin-top: 0.7rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hero-side,
|
||||
.summary-band,
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.hero-side div,
|
||||
.summary-band article,
|
||||
.detail-card {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.hero-side span,
|
||||
.summary-band span,
|
||||
.detail-card span,
|
||||
.callout span,
|
||||
.eyebrow,
|
||||
.meta-grid span,
|
||||
.inline-note span,
|
||||
.section-heading span,
|
||||
th,
|
||||
.section-heading span {
|
||||
color: #6b7a73;
|
||||
font-size: 0.72rem;
|
||||
.session-block span {
|
||||
color: #000;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-side strong,
|
||||
.detail-card strong {
|
||||
font-size: 1rem;
|
||||
.title-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summary-band {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
.title-block h1 {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.summary-band article {
|
||||
min-height: 6.2rem;
|
||||
padding: 1rem 1.05rem;
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 1.2rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
.subtitle {
|
||||
margin-top: 0.12rem;
|
||||
color: #000;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.summary-band strong {
|
||||
margin-top: auto;
|
||||
font-size: clamp(1.4rem, 2.4vw, 2rem);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
min-height: 7rem;
|
||||
padding: 1rem 1.05rem;
|
||||
border-radius: 1.15rem;
|
||||
background: #f6f9f7;
|
||||
}
|
||||
|
||||
.callout {
|
||||
.session-block {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 1.15rem;
|
||||
break-inside: avoid;
|
||||
justify-items: end;
|
||||
gap: 0.18rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.callout.notes {
|
||||
background: #f6f9f7;
|
||||
border: 1px solid #dfe7e2;
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.callout.warning {
|
||||
background: #fff7ea;
|
||||
border: 1px solid #f0cf97;
|
||||
color: #82561b;
|
||||
.meta-grid div {
|
||||
display: grid;
|
||||
gap: 0.08rem;
|
||||
min-height: 2.1rem;
|
||||
padding: 0.34rem 0.42rem;
|
||||
border: 1px solid #000;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.callout ul {
|
||||
padding-left: 1rem;
|
||||
.meta-grid strong {
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.meta-grid small {
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.inline-note {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
padding: 0.35rem 0.42rem;
|
||||
border: 1px solid #000;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.inline-note p {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.inline-note.warning {
|
||||
border-color: #000;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.composition-card {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
break-inside: avoid;
|
||||
gap: 0.35rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin-top: 0.32rem;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: -0.04em;
|
||||
margin-top: 0.05rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.section-heading p {
|
||||
color: #000;
|
||||
font-size: 0.66rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 1.2rem;
|
||||
overflow: hidden;
|
||||
table-layout: fixed;
|
||||
border: 1px solid #000;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.95rem 1rem;
|
||||
padding: 0.28rem 0.42rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e6ede9;
|
||||
border-bottom: 1px solid #000;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: table-header-group;
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
width: 32mm;
|
||||
}
|
||||
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
break-inside: avoid;
|
||||
background: #fff;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 0.7rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
td strong {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
td strong {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
color: #203128;
|
||||
tr,
|
||||
td,
|
||||
th,
|
||||
section {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero,
|
||||
.summary-band,
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
.meta-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,7 +320,7 @@
|
||||
:global(html),
|
||||
:global(body) {
|
||||
width: var(--print-page-width);
|
||||
min-height: var(--print-page-height);
|
||||
height: var(--print-page-height);
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
print-color-adjust: exact;
|
||||
@@ -404,28 +329,13 @@
|
||||
|
||||
.print-document {
|
||||
width: var(--print-page-width);
|
||||
min-height: var(--print-page-height);
|
||||
height: var(--print-page-height);
|
||||
margin: 0;
|
||||
padding: var(--print-page-padding-y) var(--print-page-padding-x);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
color: #1e2622;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.summary-band article,
|
||||
.detail-card,
|
||||
table,
|
||||
.callout {
|
||||
border-color: #d5ddd8;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.callout.warning {
|
||||
background: #fff8ef;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 0;
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
`MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_')
|
||||
);
|
||||
|
||||
async function openPdf() {
|
||||
const blob = await api.mixCalculatorSessionPdf(session.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
async function downloadPdf() {
|
||||
const blob = await api.mixCalculatorSessionPdf(session.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -29,8 +36,8 @@
|
||||
<section class="print-page">
|
||||
<div class="print-toolbar">
|
||||
<a class="secondary-button" href={`/mix-calculator/${session.id}`}>Back to session</a>
|
||||
<button class="primary-button" type="button" onclick={openPdf}>Open Styled PDF</button>
|
||||
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
|
||||
<button class="primary-button" type="button" onclick={() => window.print()}>Print / Save PDF</button>
|
||||
</div>
|
||||
|
||||
<MixCalculatorPrintDocument session={session} generatedAt={new Date().toISOString()} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
|
||||
import { featureFlags } from '$lib/features';
|
||||
@@ -11,7 +10,6 @@
|
||||
MixCalculatorPreview,
|
||||
MixCalculatorSession
|
||||
} from '$lib/types';
|
||||
import MixCalculatorPreviewModal from './MixCalculatorPreviewModal.svelte';
|
||||
import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte';
|
||||
|
||||
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
|
||||
@@ -56,11 +54,8 @@
|
||||
let formError = $state('');
|
||||
let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock products.');
|
||||
let previewLoading = $state(false);
|
||||
let saveLoading = $state(false);
|
||||
let previewModalOpen = $state(false);
|
||||
|
||||
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
|
||||
const isExistingSession = $derived(initialSession !== null);
|
||||
const availableClients = $derived(
|
||||
Array.from(new Set([...(options.clients ?? []), ...(initialSession ? [initialSession.client_name] : [])]))
|
||||
);
|
||||
@@ -198,20 +193,21 @@
|
||||
formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected product'}.`;
|
||||
});
|
||||
|
||||
function printPreview() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSessionPdf(sessionId: number) {
|
||||
async function downloadPdf() {
|
||||
const tid = toast.loading('Generating PDF…');
|
||||
try {
|
||||
const blob = await api.mixCalculatorSessionPdf(sessionId);
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(formError || 'Complete the mix details first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await api.previewMixCalculatorPdf(payload);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `mix_calculator_${sessionId}.pdf`;
|
||||
anchor.download = `mix_calculator_${payload.client_name}_${payload.mix_date}.pdf`.replace(/[^\w.-]+/g, '_');
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
@@ -223,43 +219,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openPreviewModal() {
|
||||
if (!preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewModalOpen = true;
|
||||
}
|
||||
|
||||
function closePreviewModal() {
|
||||
previewModalOpen = false;
|
||||
}
|
||||
|
||||
async function saveSession(mode: 'update' | 'create', destination: 'detail' | 'print' = 'detail') {
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveLoading = true;
|
||||
const tid = toast.loading(mode === 'update' ? 'Saving session…' : 'Creating session…');
|
||||
async function openPdf() {
|
||||
const tid = toast.loading('Opening styled PDF…');
|
||||
try {
|
||||
const saved =
|
||||
mode === 'update' && initialSession
|
||||
? await api.updateMixCalculatorSession(initialSession.id, payload)
|
||||
: await api.createMixCalculatorSession(payload);
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(formError || 'Complete the mix details first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await api.previewMixCalculatorPdf(payload);
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
toast.dismiss(tid);
|
||||
toast.success(mode === 'update' ? 'Session saved' : 'Session created');
|
||||
const target = destination === 'print' ? `/mix-calculator/${saved.id}/print` : `/mix-calculator/${saved.id}`;
|
||||
await goto(target);
|
||||
} catch (error) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to save the mix calculator session.');
|
||||
formError = error instanceof Error ? error.message : 'Unable to save the mix calculator session.';
|
||||
saveLoading = false;
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to open the styled PDF.');
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
{#if !canEdit && !initialSession}
|
||||
@@ -278,8 +258,9 @@
|
||||
<a class="secondary-button" href="/mix-calculator">Session history</a>
|
||||
{/if}
|
||||
{#if initialSession}
|
||||
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
|
||||
<button class="secondary-button" type="button" onclick={() => downloadSessionPdf(initialSession.id)}>Download PDF</button>
|
||||
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Open PDF page</a>
|
||||
<button class="primary-button" type="button" onclick={openPdf}>Open PDF in new tab</button>
|
||||
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
@@ -364,38 +345,12 @@
|
||||
|
||||
{#if canEdit}
|
||||
<div class="action-row">
|
||||
<button class="primary-button" disabled={previewLoading || saveLoading} type="button" onclick={calculatePreview}>
|
||||
<button class="primary-button" disabled={previewLoading} type="button" onclick={calculatePreview}>
|
||||
<span class="button-icon" style="--button-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
|
||||
<span>{previewLoading ? 'Calculating...' : 'Calculate mix'}</span>
|
||||
</button>
|
||||
|
||||
{#if featureFlags.mixCalculatorSessionSave}
|
||||
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create')}>
|
||||
{saveLoading ? 'Saving...' : isExistingSession ? 'Save changes' : 'Save session'}
|
||||
</button>
|
||||
|
||||
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create', 'print')}>
|
||||
<span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
|
||||
<span>{saveLoading ? 'Saving...' : 'Save & print'}</span>
|
||||
</button>
|
||||
|
||||
{#if initialSession}
|
||||
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession('create')}>
|
||||
Save as new
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={openPreviewModal}>
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
|
||||
<button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={printPreview}>
|
||||
<span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
|
||||
<span>Print</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button class="secondary-button" disabled={previewLoading || saveLoading} type="button" onclick={clearForm}>
|
||||
<button class="danger-button" disabled={previewLoading} type="button" onclick={clearForm}>
|
||||
<span class="button-icon" style="--button-icon-url: url('/icons/trash.svg');" aria-hidden="true"></span>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
@@ -406,20 +361,12 @@
|
||||
<MixCalculatorResultsPanel
|
||||
preview={preview}
|
||||
sessionNumber={initialSession?.session_number ?? null}
|
||||
onOpenPdf={preview ? openPdf : null}
|
||||
onDownloadPdf={preview ? downloadPdf : null}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{#if preview}
|
||||
{#if previewModalOpen}
|
||||
<MixCalculatorPreviewModal
|
||||
preview={preview}
|
||||
sessionId={initialSession?.id ?? null}
|
||||
onClose={closePreviewModal}
|
||||
onPrint={printPreview}
|
||||
onDownloadPdf={downloadSessionPdf}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<section class="print-only" aria-hidden="true">
|
||||
<MixCalculatorPrintDocument session={preview} />
|
||||
</section>
|
||||
@@ -474,7 +421,7 @@
|
||||
.form-card,
|
||||
.locked-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.3rem;
|
||||
border-radius: 0.8rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
@@ -511,7 +458,7 @@
|
||||
gap: 0.14rem;
|
||||
padding: 0.72rem 0.82rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.92rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -548,9 +495,20 @@
|
||||
width: 100%;
|
||||
padding: 0.78rem 0.82rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0.88rem;
|
||||
border-radius: 0.6rem;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
@@ -562,14 +520,14 @@
|
||||
gap: 0.2rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.92rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 0.85rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 0.88rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -589,21 +547,20 @@
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
.secondary-button,
|
||||
.danger-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.78rem 0.96rem;
|
||||
border-radius: 0.9rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 140ms ease,
|
||||
box-shadow 140ms ease,
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease;
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@@ -617,20 +574,35 @@
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled),
|
||||
.secondary-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
.danger-button {
|
||||
background: #c63d32;
|
||||
color: #fff;
|
||||
border-color: #c63d32;
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
box-shadow: none;
|
||||
filter: brightness(1.04);
|
||||
background: #126a33;
|
||||
}
|
||||
|
||||
.primary-button:active:not(:disabled) {
|
||||
background: #0f5a2b;
|
||||
}
|
||||
|
||||
.secondary-button:hover:not(:disabled) {
|
||||
border-color: #9fb0a6;
|
||||
background: #f6faf7;
|
||||
box-shadow: none;
|
||||
border-color: var(--color-text-muted);
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.danger-button:hover:not(:disabled) {
|
||||
background: #b2352b;
|
||||
border-color: #b2352b;
|
||||
}
|
||||
|
||||
.primary-button:focus-visible,
|
||||
.secondary-button:focus-visible,
|
||||
.danger-button:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
preview,
|
||||
sessionId = null,
|
||||
onClose,
|
||||
onPrint,
|
||||
onOpenPdf,
|
||||
onDownloadPdf
|
||||
}: {
|
||||
preview: MixCalculatorPreview | MixCalculatorSession;
|
||||
sessionId?: number | null;
|
||||
onClose: () => void;
|
||||
onPrint: () => void;
|
||||
onDownloadPdf: (sessionId: number) => void;
|
||||
onOpenPdf: () => void;
|
||||
onDownloadPdf: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
class="preview-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Print preview"
|
||||
aria-label="Styled PDF preview"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => {
|
||||
@@ -33,16 +33,16 @@
|
||||
>
|
||||
<div class="preview-modal-toolbar">
|
||||
<div>
|
||||
<p class="preview-modal-kicker">Print Preview</p>
|
||||
<p class="preview-modal-kicker">Styled PDF Preview</p>
|
||||
<h3>{preview.product_name}</h3>
|
||||
</div>
|
||||
<div class="preview-modal-actions">
|
||||
<button class="secondary-button" type="button" onclick={onClose}>Close</button>
|
||||
<button class="primary-button" type="button" onclick={onOpenPdf}>Open PDF in new tab</button>
|
||||
{#if sessionId}
|
||||
<a class="secondary-button" href={`/mix-calculator/${sessionId}/print`}>Open page</a>
|
||||
<button class="secondary-button" type="button" onclick={() => onDownloadPdf(sessionId)}>Download PDF</button>
|
||||
<a class="secondary-button" href={`/mix-calculator/${sessionId}/print`}>Open PDF page</a>
|
||||
{/if}
|
||||
<button class="primary-button" type="button" onclick={onPrint}>Print / Save PDF</button>
|
||||
<button class="secondary-button" type="button" onclick={onDownloadPdf}>Download PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,10 +77,9 @@
|
||||
width: min(1180px, 100%);
|
||||
max-height: calc(100vh - 2rem);
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.32);
|
||||
border-radius: 1.6rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(248, 250, 248, 0.96), rgba(240, 246, 242, 0.96));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.preview-modal-toolbar {
|
||||
@@ -116,9 +115,9 @@
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
padding: 1.1rem;
|
||||
border-radius: 1.35rem;
|
||||
background:
|
||||
linear-gradient(135deg, #dfe8e2 0%, #eef3ef 45%, #d7e2db 100%);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.8rem;
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.preview-sheet-scroll {
|
||||
@@ -135,10 +134,13 @@
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.78rem 0.96rem;
|
||||
border-radius: 0.9rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@@ -152,6 +154,25 @@
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
background: #126a33;
|
||||
}
|
||||
|
||||
.primary-button:active {
|
||||
background: #0f5a2b;
|
||||
}
|
||||
|
||||
.secondary-button:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.primary-button:focus-visible,
|
||||
.secondary-button:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.preview-modal-toolbar {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Download, Printer } from 'lucide-svelte';
|
||||
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
|
||||
|
||||
let {
|
||||
preview,
|
||||
sessionNumber = null
|
||||
sessionNumber = null,
|
||||
onOpenPdf = null,
|
||||
onDownloadPdf = null
|
||||
}: {
|
||||
preview: MixCalculatorPreview | MixCalculatorSession | null;
|
||||
sessionNumber?: string | null;
|
||||
onOpenPdf?: (() => void) | null;
|
||||
onDownloadPdf?: (() => void) | null;
|
||||
} = $props();
|
||||
|
||||
function formatDate(value: string) {
|
||||
@@ -89,9 +94,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raw material</th>
|
||||
<th>Mix %</th>
|
||||
<th>Required kg</th>
|
||||
<th>Unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -100,14 +103,23 @@
|
||||
<td data-label="Raw material">
|
||||
<strong>{line.raw_material_name}</strong>
|
||||
</td>
|
||||
<td data-label="Mix %">{formatNumber(line.mix_percentage, 2)}%</td>
|
||||
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
|
||||
<td data-label="Unit">{line.unit}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="output-actions">
|
||||
<button class="primary-button" disabled={!onOpenPdf} type="button" onclick={() => onOpenPdf?.()}>
|
||||
<Printer size={18} strokeWidth={1.9} aria-hidden="true" />
|
||||
Print
|
||||
</button>
|
||||
<button class="secondary-button" disabled={!onDownloadPdf} type="button" onclick={() => onDownloadPdf?.()}>
|
||||
<Download size={18} strokeWidth={1.9} aria-hidden="true" />
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<div class="empty-shimmer-metrics">
|
||||
@@ -127,8 +139,6 @@
|
||||
<div class="shimmer-row">
|
||||
<div class="shimmer-line wide"></div>
|
||||
<div class="shimmer-line medium"></div>
|
||||
<div class="shimmer-line medium"></div>
|
||||
<div class="shimmer-line short"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -152,7 +162,7 @@
|
||||
.result-card,
|
||||
.metric-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.3rem;
|
||||
border-radius: 0.8rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
@@ -179,7 +189,7 @@
|
||||
gap: 0.14rem;
|
||||
padding: 0.72rem 0.82rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.92rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -222,9 +232,9 @@
|
||||
gap: 0.45rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.92rem;
|
||||
border-radius: 1rem;
|
||||
background: #fff6e6;
|
||||
color: #8b5b1e;
|
||||
border-radius: 0.65rem;
|
||||
background: #fdf6e9;
|
||||
color: #8a5a00;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
@@ -235,7 +245,7 @@
|
||||
.summary-grid div {
|
||||
padding: 0.88rem 0.92rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -251,6 +261,14 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.output-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 30rem;
|
||||
@@ -272,6 +290,57 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.78rem 0.96rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border: none;
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: #fff;
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: #126a33;
|
||||
}
|
||||
|
||||
.primary-button:active:not(:disabled) {
|
||||
background: #0f5a2b;
|
||||
}
|
||||
|
||||
.secondary-button:hover:not(:disabled) {
|
||||
border-color: var(--color-text-muted);
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.primary-button:focus-visible,
|
||||
.secondary-button:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -355,7 +424,7 @@
|
||||
|
||||
.shimmer-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 0.75fr;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.78rem 1rem;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
active?: boolean;
|
||||
onSelect?: () => void;
|
||||
type?: 'button' | 'link';
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -33,14 +34,14 @@
|
||||
{#if Icon}
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
{/if}
|
||||
<span>{item.label}</span>
|
||||
<span>{item.label}</span>{#if item.badge}<span class="nav-badge">{item.badge}</span>{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button type="button" class="nav-button" class:active={item.active} onclick={item.onSelect}>
|
||||
{#if Icon}
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
{/if}
|
||||
<span>{item.label}</span>
|
||||
<span>{item.label}</span>{#if item.badge}<span class="nav-badge">{item.badge}</span>{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -105,6 +106,26 @@
|
||||
background: var(--nav-item-active-marker, var(--color-brand));
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
padding: 0.08rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: #fdf0d2;
|
||||
color: #8a5a00;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nav-list a.active .nav-badge,
|
||||
.nav-button.active .nav-badge {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #8a5a00;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
workingDocumentItems,
|
||||
footerItems,
|
||||
appVersion,
|
||||
releaseStage,
|
||||
currentYear,
|
||||
canOpenSettings,
|
||||
onOpenSettings,
|
||||
@@ -23,7 +22,6 @@
|
||||
workingDocumentItems: NavItem[];
|
||||
footerItems: FooterLink[];
|
||||
appVersion: string;
|
||||
releaseStage: string;
|
||||
currentYear: number;
|
||||
canOpenSettings: boolean;
|
||||
onOpenSettings: () => void;
|
||||
@@ -46,6 +44,7 @@
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
icon: item.icon,
|
||||
badge: item.badge,
|
||||
active: matchesRoute(item.href, currentPath)
|
||||
}))}
|
||||
/>
|
||||
@@ -105,7 +104,6 @@
|
||||
<span class="meta-label">Build</span>
|
||||
<span>{appVersion}</span>
|
||||
</span>
|
||||
<span class="release-pill">{releaseStage}</span>
|
||||
</div>
|
||||
<div class="sidebar-meta-bottom">
|
||||
<small>© {currentYear} Hunter Premium Produce</small>
|
||||
@@ -242,18 +240,4 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.2rem 0.52rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-brand) 8%, white);
|
||||
color: var(--color-brand);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ClipboardList,
|
||||
DollarSign,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
LayoutDashboard,
|
||||
ShieldCheck,
|
||||
TrendingUp,
|
||||
@@ -13,6 +14,10 @@ import {
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
import { featureFlags } from '$lib/features';
|
||||
import {
|
||||
canOpenDashboard
|
||||
} from '$lib/workspace-access';
|
||||
import type { AppSession } from '$lib/session';
|
||||
|
||||
export type SearchItem = {
|
||||
href: string;
|
||||
@@ -27,6 +32,7 @@ export type NavItem = {
|
||||
shortLabel: string;
|
||||
icon: ComponentType;
|
||||
moduleKey?: string;
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
export type FooterLink = {
|
||||
@@ -50,7 +56,7 @@ export const dashboardItem: NavItem = {
|
||||
};
|
||||
|
||||
export const mixCalculatorItem: NavItem = {
|
||||
href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new',
|
||||
href: '/mix-calculator',
|
||||
label: 'Mix Calculator',
|
||||
shortLabel: 'MC',
|
||||
icon: Calculator,
|
||||
@@ -65,6 +71,15 @@ export const reportingItem: NavItem = {
|
||||
moduleKey: 'products'
|
||||
};
|
||||
|
||||
export const throughputItem: NavItem = {
|
||||
href: '/throughput',
|
||||
label: 'Throughput',
|
||||
shortLabel: 'OT',
|
||||
icon: Gauge,
|
||||
moduleKey: 'operations_throughput',
|
||||
badge: 'test'
|
||||
};
|
||||
|
||||
export const workingDocumentItems: NavItem[] = [
|
||||
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
|
||||
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
|
||||
@@ -83,6 +98,7 @@ export const accessControlItem: NavItem = {
|
||||
export const clientNavigationItems: NavItem[] = [
|
||||
dashboardItem,
|
||||
mixCalculatorItem,
|
||||
throughputItem,
|
||||
...workingDocumentItems,
|
||||
accessControlItem
|
||||
];
|
||||
@@ -128,8 +144,8 @@ export const baseSearchItems: SearchItem[] = [
|
||||
]
|
||||
: []),
|
||||
{
|
||||
href: '/mix-calculator/new',
|
||||
label: 'Create Mix Calculation',
|
||||
href: '/mix-calculator',
|
||||
label: 'Open Mix Calculator',
|
||||
description: 'Run a new client-specific mix calculation session.',
|
||||
keywords: 'new mix calculator session client batch size product bags print'
|
||||
},
|
||||
@@ -167,26 +183,30 @@ export function pageTitle(pathname: string) {
|
||||
return clientNavigationItems.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
|
||||
}
|
||||
|
||||
export function clientBreadcrumbs(pathname: string): Crumb[] {
|
||||
const root: Crumb = { label: 'Workspace', href: '/' };
|
||||
export function clientBreadcrumbs(pathname: string, session?: AppSession | null): Crumb[] {
|
||||
const crumbs: Crumb[] = [];
|
||||
|
||||
if (canOpenDashboard(session)) {
|
||||
crumbs.push({ label: 'Workspace', href: '/' });
|
||||
}
|
||||
|
||||
if (pathname === '/') {
|
||||
return [root, { label: 'Dashboard' }];
|
||||
return crumbs.length ? [...crumbs, { label: 'Dashboard' }] : [{ label: 'Dashboard' }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/mix-calculator')) {
|
||||
const trail: Crumb[] = [root, { label: 'Mix Calculator', href: '/mix-calculator' }];
|
||||
if (pathname === '/mix-calculator/new') trail.push({ label: 'New Session' });
|
||||
else if (pathname.endsWith('/print')) trail.push({ label: 'Print' });
|
||||
else if (pathname !== '/mix-calculator') trail.push({ label: 'Session' });
|
||||
return trail;
|
||||
return [...crumbs, { label: 'Mix Calculator' }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/mixes')) {
|
||||
const trail: Crumb[] = [root, { label: 'Mix Master', href: '/mixes' }];
|
||||
if (pathname === '/mixes/new') trail.push({ label: 'New Mix' });
|
||||
else if (pathname !== '/mixes') trail.push({ label: 'Detail' });
|
||||
return trail;
|
||||
return [...crumbs, { label: 'Mix Master' }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/throughput')) {
|
||||
const base: Crumb[] = [...crumbs, { label: 'Throughput', href: '/throughput' }];
|
||||
if (pathname === '/throughput/add') return [...base, { label: 'Add Entry' }];
|
||||
if (pathname === '/throughput') return base.slice(0, -1).concat([{ label: 'Throughput' }]);
|
||||
return base;
|
||||
}
|
||||
|
||||
const sectionMap: Record<string, string> = {
|
||||
@@ -195,10 +215,11 @@ export function clientBreadcrumbs(pathname: string): Crumb[] {
|
||||
'/scenarios': 'Scenarios',
|
||||
'/client-access': 'Client Access',
|
||||
'/reporting': 'Reporting',
|
||||
'/settings': 'Settings'
|
||||
'/settings': 'Settings',
|
||||
'/throughput': 'Throughput'
|
||||
};
|
||||
const section = sectionMap[pathname];
|
||||
if (section) return [root, { label: section }];
|
||||
if (section) return [...crumbs, { label: section }];
|
||||
|
||||
return [root, { label: pageTitle(pathname) }];
|
||||
return [...crumbs, { label: pageTitle(pathname) }];
|
||||
}
|
||||
|
||||
@@ -388,3 +388,89 @@ export type ClientUserUpdateInput = {
|
||||
status?: string;
|
||||
is_new_user?: boolean;
|
||||
};
|
||||
|
||||
export type ThroughputProduct = {
|
||||
id: number;
|
||||
tenant_id: string;
|
||||
item_id: string | null;
|
||||
name: string;
|
||||
default_bag_size: number | null;
|
||||
is_bulka_default: boolean;
|
||||
active: boolean;
|
||||
is_stock_item: boolean;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ThroughputQuantityType = 'bags' | 'kg';
|
||||
|
||||
export type ThroughputEntry = {
|
||||
id: number;
|
||||
tenant_id: string;
|
||||
production_date: string;
|
||||
product_id: number | null;
|
||||
product_name_snapshot: string;
|
||||
bag_size: number | null;
|
||||
scales_checked: boolean;
|
||||
label_correct: boolean;
|
||||
bag_sealed: boolean;
|
||||
pallet_good_condition: boolean;
|
||||
sample_box_no: string | null;
|
||||
test_weight_1: number | null;
|
||||
test_weight_2: number | null;
|
||||
test_weight_3: number | null;
|
||||
test_weight_4: number | null;
|
||||
test_weight_5: number | null;
|
||||
quantity: number;
|
||||
quantity_type: ThroughputQuantityType;
|
||||
calculated_kg: number;
|
||||
staff_name: string | null;
|
||||
notes: string | null;
|
||||
qa_passed: boolean;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ThroughputEntryCreateInput = {
|
||||
production_date: string;
|
||||
product_id?: number | null;
|
||||
product_name_snapshot?: string;
|
||||
bag_size?: number | null;
|
||||
scales_checked?: boolean;
|
||||
label_correct?: boolean;
|
||||
bag_sealed?: boolean;
|
||||
pallet_good_condition?: boolean;
|
||||
sample_box_no?: string | null;
|
||||
test_weight_1?: number | null;
|
||||
test_weight_2?: number | null;
|
||||
test_weight_3?: number | null;
|
||||
test_weight_4?: number | null;
|
||||
test_weight_5?: number | null;
|
||||
quantity: number;
|
||||
quantity_type: ThroughputQuantityType;
|
||||
staff_name?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type ThroughputEntryListParams = {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
product_id?: number | null;
|
||||
staff_name?: string;
|
||||
quantity_type?: ThroughputQuantityType;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type ThroughputProductCreateInput = {
|
||||
item_id?: string | null;
|
||||
name: string;
|
||||
default_bag_size?: number | null;
|
||||
is_bulka_default?: boolean;
|
||||
active?: boolean;
|
||||
is_stock_item?: boolean;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type ThroughputProductUpdateInput = Partial<ThroughputProductCreateInput>;
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('workspace access policy', () => {
|
||||
|
||||
it('classifies operations users and sends them to mix calculator by default', () => {
|
||||
expect(getWorkspaceRole(operationsSession)).toBe('operations');
|
||||
expect(getDefaultRouteForRole(operationsSession)).toBe('/mix-calculator/new');
|
||||
expect(getDefaultRouteForRole(operationsSession)).toBe('/mix-calculator');
|
||||
});
|
||||
|
||||
it('prevents operations users from opening the dashboard route', () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { featureFlags } from '$lib/features';
|
||||
import { hasModuleAccess, hasPermission, type AppSession } from '$lib/session';
|
||||
|
||||
export type WorkspaceRole = 'admin' | 'operations' | 'full' | 'client' | 'unknown';
|
||||
@@ -90,6 +89,14 @@ export function canOpenScenarios(session: AppSession | null | undefined) {
|
||||
return !!session && hasModuleAccess(session, 'scenarios');
|
||||
}
|
||||
|
||||
export function canOpenThroughput(session: AppSession | null | undefined) {
|
||||
return canAccessWorkspaceArea(session, 'operations_throughput', ['view_throughput', 'edit_throughput']);
|
||||
}
|
||||
|
||||
export function canEditThroughput(session: AppSession | null | undefined) {
|
||||
return canAccessWorkspaceArea(session, 'operations_throughput', ['edit_throughput'], 'edit');
|
||||
}
|
||||
|
||||
export function canOpenReporting(session: AppSession | null | undefined) {
|
||||
return canOpenProducts(session);
|
||||
}
|
||||
@@ -133,6 +140,11 @@ export const routeAccessRules: RouteAccessRule[] = [
|
||||
path: '/client-access',
|
||||
roles: ['admin', 'client'],
|
||||
matches: (pathname) => hasPathPrefix(pathname, '/client-access')
|
||||
},
|
||||
{
|
||||
path: '/throughput',
|
||||
roles: ['admin', 'operations', 'full', 'client'],
|
||||
matches: (pathname) => hasPathPrefix(pathname, '/throughput')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -140,12 +152,12 @@ export function getDefaultRouteForRole(session: AppSession | null | undefined) {
|
||||
const role = getWorkspaceRole(session);
|
||||
|
||||
if (role === 'operations') {
|
||||
return featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new';
|
||||
return '/mix-calculator';
|
||||
}
|
||||
|
||||
if (role === 'admin' || role === 'full' || role === 'client') {
|
||||
if (canOpenDashboard(session)) return '/';
|
||||
if (canOpenMixCalculator(session)) return featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new';
|
||||
if (canOpenMixCalculator(session)) return '/mix-calculator';
|
||||
if (canOpenRawMaterials(session)) return '/raw-materials';
|
||||
if (canOpenMixMaster(session)) return '/mixes';
|
||||
if (canOpenProducts(session)) return '/products';
|
||||
@@ -176,6 +188,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
|
||||
if (pathname.startsWith('/reporting')) return canOpenReporting(session);
|
||||
if (pathname.startsWith('/settings')) return canOpenSettings(session);
|
||||
if (pathname.startsWith('/client-access')) return canOpenClientAccess(session);
|
||||
if (pathname.startsWith('/throughput')) return canOpenThroughput(session);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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