tweaks
This commit is contained in:
+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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user