Access permissions, seed permissions, security, session, api/session improved handling + speed across the site/UX improvements
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { clientSession, sessionHydrated } from '$lib/session';
|
||||
import type { Mix, ProductCostBreakdown, RawMaterial } from '$lib/types';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import type { DashboardSummary } from '$lib/types';
|
||||
import packageInfo from '../../package.json';
|
||||
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
|
||||
|
||||
type Segment = {
|
||||
label: string;
|
||||
@@ -44,7 +46,10 @@
|
||||
isLoggingIn = true;
|
||||
|
||||
try {
|
||||
const session = await api.clientLogin(email, password);
|
||||
// Authenticates against the internal Hunter Stock Feeds role/permission
|
||||
// system. The response is shape-compatible with the legacy client
|
||||
// session, so the rest of the app continues to work unchanged.
|
||||
const session = await api.internalLogin(email, password);
|
||||
clientSession.set(session);
|
||||
} catch (error) {
|
||||
loginError = error instanceof Error ? error.message : 'Unable to sign in';
|
||||
@@ -99,7 +104,8 @@
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function greetingForAst() {
|
||||
// Australian Eastern time-of-day → greeting + matching Lucide icon.
|
||||
function timeOfDay() {
|
||||
const astHour = Number(
|
||||
new Intl.DateTimeFormat('en-AU', {
|
||||
hour: 'numeric',
|
||||
@@ -108,34 +114,36 @@
|
||||
}).format(new Date())
|
||||
);
|
||||
|
||||
return astHour < 12 ? 'Good morning' : 'Good evening';
|
||||
if (astHour >= 5 && astHour < 12) return { label: 'Good morning', icon: Sunrise, tone: 'morning' as const };
|
||||
if (astHour >= 12 && astHour < 17) return { label: 'Good afternoon', icon: Sun, tone: 'afternoon' as const };
|
||||
if (astHour >= 17 && astHour < 21) return { label: 'Good evening', icon: Sunset, tone: 'evening' as const };
|
||||
return { label: 'Good evening', icon: Moon, tone: 'night' as const };
|
||||
}
|
||||
|
||||
function firstName(name: string | null | undefined) {
|
||||
return name?.trim().split(/\s+/)[0] ?? 'there';
|
||||
}
|
||||
|
||||
function findHighestProduct(products: ProductCostBreakdown[]) {
|
||||
return [...products].sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)[0];
|
||||
}
|
||||
// The dashboard summary streams in after the route shell paints. Until it
|
||||
// resolves, all derived state falls back to defaults so the page chrome
|
||||
// stays interactive.
|
||||
let summary = $state<DashboardSummary | null>(null);
|
||||
|
||||
function findMostExpensiveMix(mixes: Mix[]) {
|
||||
return [...mixes].sort((left, right) => (right.mix_cost_per_kg ?? 0) - (left.mix_cost_per_kg ?? 0))[0];
|
||||
}
|
||||
$effect(() => {
|
||||
let cancelled = false;
|
||||
Promise.resolve(data.summary).then((value) => {
|
||||
if (!cancelled) summary = value;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
function findLatestMaterial(materials: RawMaterial[]) {
|
||||
return [...materials].sort((left, right) => {
|
||||
const leftDate = left.current_price?.effective_date ?? '';
|
||||
const rightDate = right.current_price?.effective_date ?? '';
|
||||
return rightDate.localeCompare(leftDate);
|
||||
})[0];
|
||||
}
|
||||
|
||||
function buildSegments() {
|
||||
function buildSegments(current: DashboardSummary | null) {
|
||||
return [
|
||||
{ label: 'Materials', value: data.rawMaterials.length, color: '#2c9b5f' },
|
||||
{ label: 'Mixes', value: data.mixes.length, color: '#d7802a' },
|
||||
{ label: 'Products', value: data.productCosts.length, color: '#286ea7' }
|
||||
{ label: 'Materials', value: current?.raw_materials?.count ?? 0, color: '#2c9b5f' },
|
||||
{ label: 'Mixes', value: current?.mixes?.count ?? 0, color: '#d7802a' },
|
||||
{ label: 'Products', value: current?.products?.count ?? 0, color: '#286ea7' }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -176,11 +184,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
function buildTrendSeries() {
|
||||
function buildTrendSeries(current: DashboardSummary | null) {
|
||||
const trends = current?.trend_seeds;
|
||||
const seeds = [
|
||||
...data.rawMaterials.map((material: RawMaterial) => (material.current_price?.cost_per_kg ?? 0) * 780),
|
||||
...data.mixes.map((mix: Mix) => (mix.mix_cost_per_kg ?? 0) * 640),
|
||||
...data.productCosts.map((product: ProductCostBreakdown) => product.finished_product_delivered * 24)
|
||||
...(trends?.raw_material_cost_per_kg ?? []).map((value) => value * 780),
|
||||
...(trends?.mix_cost_per_kg ?? []).map((value) => value * 640),
|
||||
...(trends?.product_finished_delivered ?? []).map((value) => value * 24)
|
||||
].filter((value) => value > 0);
|
||||
|
||||
const source = seeds.length ? seeds : [320, 360, 420];
|
||||
@@ -237,23 +246,23 @@
|
||||
};
|
||||
}
|
||||
|
||||
function buildFocusCards(): WorkspaceFocus[] {
|
||||
const featuredMaterial = findLatestMaterial(data.rawMaterials);
|
||||
const featuredMix = findMostExpensiveMix(data.mixes);
|
||||
const featuredProduct = findHighestProduct(data.productCosts);
|
||||
function buildFocusCards(current: DashboardSummary | null): WorkspaceFocus[] {
|
||||
const featuredMaterial = current?.raw_materials?.latest ?? null;
|
||||
const featuredMix = current?.mixes?.top ?? null;
|
||||
const featuredProduct = current?.products?.top ?? null;
|
||||
|
||||
return [
|
||||
{
|
||||
code: 'RM',
|
||||
label: featuredMaterial?.name ?? 'Raw material',
|
||||
detail: `Updated ${formatDate(featuredMaterial?.current_price?.effective_date)}`,
|
||||
value: currency(featuredMaterial?.current_price?.market_value),
|
||||
detail: `Updated ${formatDate(featuredMaterial?.effective_date)}`,
|
||||
value: currency(featuredMaterial?.market_value),
|
||||
tone: 'positive'
|
||||
},
|
||||
{
|
||||
code: 'MX',
|
||||
label: featuredMix?.name ?? 'Mix worksheet',
|
||||
detail: `${featuredMix?.ingredients.length ?? 0} ingredients loaded`,
|
||||
detail: `${featuredMix?.ingredients_count ?? 0} ingredients loaded`,
|
||||
value: `${currency(featuredMix?.mix_cost_per_kg, 4)} / kg`,
|
||||
tone: featuredMix?.warnings.length ? 'warning' : 'neutral'
|
||||
},
|
||||
@@ -267,35 +276,24 @@
|
||||
];
|
||||
}
|
||||
|
||||
const featuredProduct = $derived(findHighestProduct(data.productCosts));
|
||||
const featuredMix = $derived(findMostExpensiveMix(data.mixes));
|
||||
const featuredMaterial = $derived(findLatestMaterial(data.rawMaterials));
|
||||
const productionSegments = $derived(buildSegments());
|
||||
const featuredProduct = $derived(summary?.products?.top ?? null);
|
||||
const featuredMix = $derived(summary?.mixes?.top ?? null);
|
||||
const featuredMaterial = $derived(summary?.raw_materials?.latest ?? null);
|
||||
const productionSegments = $derived(buildSegments(summary));
|
||||
const gaugeBars = $derived(buildGaugeBars(productionSegments));
|
||||
const totalTracked = $derived(
|
||||
productionSegments.reduce((sum: number, segment: Segment) => sum + segment.value, 0)
|
||||
);
|
||||
const totalMarketValue = $derived(
|
||||
data.rawMaterials.reduce(
|
||||
(sum: number, material: RawMaterial) => sum + (material.current_price?.market_value ?? 0),
|
||||
0
|
||||
)
|
||||
);
|
||||
const averageMixCost = $derived(
|
||||
data.mixes.length
|
||||
? data.mixes.reduce((sum: number, mix: Mix) => sum + (mix.mix_cost_per_kg ?? 0), 0) / data.mixes.length
|
||||
: 0
|
||||
);
|
||||
const trendSeries = $derived(buildTrendSeries());
|
||||
const totalMarketValue = $derived(summary?.raw_materials?.total_market_value ?? 0);
|
||||
const averageMixCost = $derived(summary?.mixes?.average_cost_per_kg ?? 0);
|
||||
const trendSeries = $derived(buildTrendSeries(summary));
|
||||
const trendLine = $derived(linePath(trendSeries));
|
||||
const trendArea = $derived(areaPath(trendSeries));
|
||||
const trendFocus = $derived(focusMarker(trendSeries));
|
||||
const topProducts = $derived(
|
||||
[...data.productCosts]
|
||||
.sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)
|
||||
.slice(0, 4)
|
||||
);
|
||||
const focusCards = $derived(buildFocusCards());
|
||||
const topProducts = $derived(summary?.products?.top_products ?? []);
|
||||
const focusCards = $derived(buildFocusCards(summary));
|
||||
const loading = $derived(summary === null);
|
||||
const greeting = $derived(timeOfDay());
|
||||
</script>
|
||||
|
||||
{#if !$sessionHydrated}
|
||||
@@ -403,40 +401,41 @@
|
||||
</section>
|
||||
{:else}
|
||||
<section class="dashboard-intro">
|
||||
<div>
|
||||
<div class="hero-label-row">
|
||||
<p class="eyebrow">Client Workspace</p>
|
||||
<span class="release-pill">{releaseStage}</span>
|
||||
</div>
|
||||
<h2>{greetingForAst()}, {firstName($clientSession?.name)}</h2>
|
||||
<p>Track input pricing, mix performance, and delivered product outcomes from one client-facing workspace.</p>
|
||||
<div class="greeting-row">
|
||||
{#snippet greetIcon()}
|
||||
{@const Icon = greeting.icon}
|
||||
<span class={`greeting-icon ${greeting.tone}`} aria-hidden="true">
|
||||
<Icon size={44} strokeWidth={1.6} />
|
||||
</span>
|
||||
{/snippet}
|
||||
{@render greetIcon()}
|
||||
<h2>{greeting.label}, {firstName($clientSession?.name)}</h2>
|
||||
</div>
|
||||
|
||||
<div class="intro-actions">
|
||||
<button class="secondary-button" type="button">Apr, 2026</button>
|
||||
<a class="primary-button" href="/products">Review Delivered Pricing</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-banner">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h3>Hunter Premium Produce</h3>
|
||||
<p>Lean 101 powers the client workspace while operator-only administration now lives in the separate `/admin` area.</p>
|
||||
</div>
|
||||
|
||||
<div class="focus-grid">
|
||||
{#each focusCards as card}
|
||||
<article class={`focus-card ${card.tone}`}>
|
||||
<span class="focus-code">{card.code}</span>
|
||||
<div>
|
||||
<section class="focus-row">
|
||||
{#each focusCards as card, i}
|
||||
<article class={`focus-card ${card.tone}`}>
|
||||
<span class="focus-code">{card.code}</span>
|
||||
<div>
|
||||
{#if loading}
|
||||
<Skeleton width="9rem" height="0.95rem" />
|
||||
<Skeleton width="6rem" height="0.7rem" />
|
||||
{:else}
|
||||
<strong>{card.label}</strong>
|
||||
<span>{card.detail}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if loading}
|
||||
<Skeleton width="4rem" height="1rem" />
|
||||
{:else}
|
||||
<em>{card.value}</em>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
@@ -451,13 +450,21 @@
|
||||
|
||||
<div class="market-layout">
|
||||
<div>
|
||||
<h3>{featuredMaterial?.name ?? 'No material loaded'}</h3>
|
||||
<p>{formatDate(featuredMaterial?.current_price?.effective_date)}</p>
|
||||
<div class="hero-value">{currency(featuredMaterial?.current_price?.market_value)}</div>
|
||||
<p class="support-text">
|
||||
{currency(featuredMaterial?.current_price?.cost_per_kg, 4)} / kg
|
||||
<span>Current blend for Hunter Premium Produce</span>
|
||||
</p>
|
||||
{#if loading}
|
||||
<Skeleton width="14rem" height="1.5rem" />
|
||||
<div style="height:0.5rem"></div>
|
||||
<Skeleton width="8rem" height="0.85rem" />
|
||||
<div class="hero-value"><Skeleton width="9rem" height="2.6rem" /></div>
|
||||
<Skeleton width="11rem" height="0.85rem" />
|
||||
{:else}
|
||||
<h3>{featuredMaterial?.name ?? 'No material loaded'}</h3>
|
||||
<p>{formatDate(featuredMaterial?.effective_date)}</p>
|
||||
<div class="hero-value">{currency(featuredMaterial?.market_value)}</div>
|
||||
<p class="support-text">
|
||||
{currency(featuredMaterial?.cost_per_kg, 4)} / kg
|
||||
<span>Current blend for Hunter Premium Produce</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field-emblem" aria-hidden="true">
|
||||
@@ -512,7 +519,11 @@
|
||||
<span>Total Input Spend</span>
|
||||
<span class="metric-icon"></span>
|
||||
</div>
|
||||
<strong>{currency(totalMarketValue)}</strong>
|
||||
{#if loading}
|
||||
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
|
||||
{:else}
|
||||
<strong>{currency(totalMarketValue)}</strong>
|
||||
{/if}
|
||||
<p>Across all tracked raw materials</p>
|
||||
</article>
|
||||
|
||||
@@ -521,7 +532,11 @@
|
||||
<span>Average Mix Cost</span>
|
||||
<span class="metric-icon"></span>
|
||||
</div>
|
||||
<strong>{currency(averageMixCost, 4)}</strong>
|
||||
{#if loading}
|
||||
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
|
||||
{:else}
|
||||
<strong>{currency(averageMixCost, 4)}</strong>
|
||||
{/if}
|
||||
<p>Per kg across the current mix set</p>
|
||||
</article>
|
||||
|
||||
@@ -530,8 +545,13 @@
|
||||
<span>Top Delivered Output</span>
|
||||
<span class="metric-icon"></span>
|
||||
</div>
|
||||
<strong>{currency(featuredProduct?.finished_product_delivered)}</strong>
|
||||
<p>{featuredProduct?.product_name ?? 'No products loaded'}</p>
|
||||
{#if loading}
|
||||
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
|
||||
<p><Skeleton width="9rem" height="0.85rem" /></p>
|
||||
{:else}
|
||||
<strong>{currency(featuredProduct?.finished_product_delivered)}</strong>
|
||||
<p>{featuredProduct?.product_name ?? 'No products loaded'}</p>
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -599,7 +619,7 @@
|
||||
<div class="preview-facts">
|
||||
<article>
|
||||
<span>Ingredients</span>
|
||||
<strong>{featuredMix?.ingredients.length ?? 0}</strong>
|
||||
<strong>{featuredMix?.ingredients_count ?? 0}</strong>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
@@ -996,12 +1016,30 @@
|
||||
|
||||
.dashboard-intro,
|
||||
.workspace-banner,
|
||||
.focus-row,
|
||||
.dashboard-grid,
|
||||
.analysis-grid,
|
||||
.detail-grid {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.focus-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.focus-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.focus-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.dashboard-intro h2 {
|
||||
font-size: clamp(1.4rem, 2.4vw, 1.85rem);
|
||||
}
|
||||
|
||||
.dashboard-intro,
|
||||
.card-toolbar,
|
||||
.metric-head,
|
||||
@@ -1020,15 +1058,28 @@
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.dashboard-intro h2,
|
||||
.workspace-banner h3 {
|
||||
.dashboard-intro h2 {
|
||||
margin: 0.3rem 0 0.35rem;
|
||||
font-size: clamp(1.8rem, 3vw, 2.35rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-intro p:last-child,
|
||||
.workspace-banner p:last-child,
|
||||
.greeting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.greeting-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-toolbar p,
|
||||
.metric-card p,
|
||||
.preview-header p,
|
||||
|
||||
@@ -1,42 +1,38 @@
|
||||
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||
import type { DashboardSummary } from '$lib/types';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
const EMPTY_SUMMARY: DashboardSummary = {
|
||||
raw_materials: null,
|
||||
mixes: null,
|
||||
products: null,
|
||||
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] }
|
||||
};
|
||||
|
||||
// Streaming load: the route shell paints immediately and the dashboard fills
|
||||
// in once `summary` resolves. This replaces the previous load that awaited
|
||||
// five separate full collections (raw materials, mixes, all product cost
|
||||
// breakdowns, scenarios, data-quality) before SvelteKit would render anything.
|
||||
export function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return {
|
||||
rawMaterials: [],
|
||||
mixes: [],
|
||||
productCosts: [],
|
||||
scenarios: [],
|
||||
dataQuality: []
|
||||
};
|
||||
return { summary: Promise.resolve(EMPTY_SUMMARY) };
|
||||
}
|
||||
|
||||
// Skip data fetching for sessions that lack any dashboard-eligible module
|
||||
// — the backend would just return nulls anyway.
|
||||
const session = getStoredClientSession();
|
||||
|
||||
try {
|
||||
const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([
|
||||
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'mix_master') ? api.mixes(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'scenarios') ? api.scenarios(fetch) : Promise.resolve([]),
|
||||
hasModuleAccess(session, 'dashboard') ? api.dataQuality(fetch) : Promise.resolve([])
|
||||
]);
|
||||
|
||||
return {
|
||||
rawMaterials,
|
||||
mixes,
|
||||
productCosts,
|
||||
scenarios,
|
||||
dataQuality
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
rawMaterials: [],
|
||||
mixes: [],
|
||||
productCosts: [],
|
||||
scenarios: [],
|
||||
dataQuality: []
|
||||
};
|
||||
const permissions = session?.module_permissions ?? {};
|
||||
const hasAnyDashboardData =
|
||||
session?.role === 'admin' ||
|
||||
permissions.dashboard ||
|
||||
permissions.raw_materials ||
|
||||
permissions.mix_master ||
|
||||
permissions.products;
|
||||
if (!hasAnyDashboardData) {
|
||||
return { summary: Promise.resolve(EMPTY_SUMMARY) };
|
||||
}
|
||||
|
||||
return {
|
||||
summary: api.dashboardSummary(fetch).catch(() => EMPTY_SUMMARY)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,14 +151,6 @@
|
||||
const previewJson = $derived(JSON.stringify(exportPreview, null, 2));
|
||||
</script>
|
||||
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Client Amend Area</p>
|
||||
<h2>Control new users, existing users, and every feature flag in one operational workspace.</h2>
|
||||
<p>The preview shows the live Power BI export payload after each amendment so the admin surface and reporting output stay aligned.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-row">
|
||||
<article class="metric-card">
|
||||
<span>Total Clients</span>
|
||||
|
||||
@@ -18,16 +18,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Mix Calculator</p>
|
||||
<h2>Saved production sessions</h2>
|
||||
<p>Each session preserves the scaled raw material output so it can be reopened or printed later without relying on live recipe changes.</p>
|
||||
</div>
|
||||
{#if canEdit}
|
||||
{#if canEdit}
|
||||
<section class="page-actions">
|
||||
<a class="primary-button" href="/mix-calculator/new">New mix session</a>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="metric-row">
|
||||
<article class="metric-card">
|
||||
@@ -123,27 +118,17 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-intro,
|
||||
.page-actions,
|
||||
.metric-row,
|
||||
.table-card {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.page-intro {
|
||||
.page-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.page-intro h2 {
|
||||
margin: 0.35rem 0 0.45rem;
|
||||
max-width: 15ch;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-intro p:last-child,
|
||||
.metric-card p,
|
||||
.table-toolbar p,
|
||||
tbody span {
|
||||
@@ -283,7 +268,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page-intro,
|
||||
.table-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -104,16 +104,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Mix Master</p>
|
||||
<h2>Saved mixes in a clean table view.</h2>
|
||||
<p>Use the table to browse mixes, then open a dedicated worksheet page to edit or create a formulation.</p>
|
||||
</div>
|
||||
|
||||
<div class="intro-actions">
|
||||
<a class="primary-button" href="/mixes/new">New Mix Worksheet</a>
|
||||
</div>
|
||||
<section class="page-actions">
|
||||
<a class="primary-button" href="/mixes/new">New Mix Worksheet</a>
|
||||
</section>
|
||||
|
||||
<section class="metric-row">
|
||||
@@ -221,13 +213,17 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-intro,
|
||||
.page-actions,
|
||||
.metric-row,
|
||||
.table-card {
|
||||
margin-bottom: 1.12rem;
|
||||
}
|
||||
|
||||
.page-intro,
|
||||
.page-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.table-card {
|
||||
background: var(--panel);
|
||||
|
||||
@@ -46,14 +46,6 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Output Pricing</p>
|
||||
<h2>Delivered product pricing</h2>
|
||||
<p>Each row carries the product, mix source, price outputs, and a quick health state in one compact layout.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-row">
|
||||
<article class="metric-card">
|
||||
<span>Total Products</span>
|
||||
|
||||
@@ -152,19 +152,6 @@
|
||||
<a href="/">Return to sign-in</a>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Input Cost Control</p>
|
||||
<h2>Maintain raw materials with a cleaner operational workflow.</h2>
|
||||
<p>Update source pricing, track downstream exposure, and keep the costing engine current from one workspace.</p>
|
||||
</div>
|
||||
|
||||
<div class="intro-chip">
|
||||
<span>{$clientSession.email}</span>
|
||||
<strong>{activeMaterials.length} active materials</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if successMessage}
|
||||
<p class="feedback success">{successMessage}</p>
|
||||
{/if}
|
||||
|
||||
@@ -11,14 +11,6 @@
|
||||
const approvedCount = $derived(scenarioRows.filter((scenario) => scenario.status === 'approved').length);
|
||||
</script>
|
||||
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Scenario Sandbox</p>
|
||||
<h2>Simulation workspaces with a cleaner review and comparison layer.</h2>
|
||||
<p>Scenarios now read like structured operating plans instead of raw debug output.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-row">
|
||||
<article class="metric-card">
|
||||
<span>Total Scenarios</span>
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
const currentYear = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Workspace Settings</p>
|
||||
<h2>Account and workspace preferences.</h2>
|
||||
<p>Review your current session, navigation setup, and the client workspace details shown across the app.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-grid">
|
||||
<article class="surface-card">
|
||||
<p class="eyebrow">Session</p>
|
||||
|
||||
Reference in New Issue
Block a user