v1.3 - client and admin scaffolding

This commit is contained in:
2026-04-25 22:51:36 +12:00
parent bc211ffcc8
commit 8cf9bfb441
54 changed files with 8882 additions and 1248 deletions
+14 -189
View File
@@ -1,194 +1,19 @@
<script lang="ts">
import { page } from '$app/state';
import { operatorSession } from '$lib/session';
import AdminShell from '$lib/components/AdminShell.svelte';
import ClientShell from '$lib/components/ClientShell.svelte';
const links = [
{ href: '/', label: 'Home' },
{ href: '/raw-materials', label: 'Raw Materials' },
{ href: '/mixes', label: 'Mix Master' },
{ href: '/products', label: 'Products' },
{ href: '/scenarios', label: 'Scenarios' }
];
let { children } = $props();
const isAdminRoute = $derived(page.url.pathname === '/admin' || page.url.pathname.startsWith('/admin/'));
</script>
<svelte:head>
<title>Data Entry App</title>
</svelte:head>
<div class="shell">
<header class="topbar">
<div class="brand-block">
<a class="brand" href="/">Data Entry App</a>
<p>Operator costing workflow</p>
</div>
<nav class="topnav" aria-label="Primary navigation">
{#each links as link}
<a class:active={page.url.pathname === link.href} href={link.href}>{link.label}</a>
{/each}
</nav>
<div class="session-panel">
{#if $operatorSession}
<div>
<span>Signed in</span>
<strong>{$operatorSession.name}</strong>
</div>
<button type="button" onclick={() => operatorSession.clear()}>Sign out</button>
{:else}
<a class="login-link" href="/">Operator login</a>
{/if}
</div>
</header>
<main class="content">
<slot />
</main>
</div>
<style>
:global(:root) {
--canvas: #f5efe4;
--canvas-strong: #fffaf1;
--ink: #20170f;
--muted: #695746;
--line: rgba(74, 53, 31, 0.14);
--brand: #8f4f1f;
--brand-deep: #5a2d18;
--accent: #d9a441;
--shadow: 0 18px 50px rgba(56, 38, 19, 0.12);
}
:global(body) {
margin: 0;
color: var(--ink);
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
background:
radial-gradient(circle at top right, rgba(217, 164, 65, 0.22), transparent 24rem),
radial-gradient(circle at left center, rgba(143, 79, 31, 0.1), transparent 28rem),
linear-gradient(180deg, #f7f1e7 0%, #efe5d3 100%);
}
:global(*) {
box-sizing: border-box;
}
.shell {
min-height: 100vh;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
align-items: center;
padding: 1rem 2rem;
backdrop-filter: blur(16px);
background: rgba(247, 241, 231, 0.88);
border-bottom: 1px solid var(--line);
}
.brand-block {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.brand {
color: var(--brand-deep);
text-decoration: none;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.brand-block p,
.session-panel span {
margin: 0;
color: var(--muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.topnav {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.65rem;
}
.topnav a,
.login-link,
button {
border: 1px solid transparent;
border-radius: 999px;
padding: 0.75rem 1rem;
color: var(--brand-deep);
text-decoration: none;
background: transparent;
font: inherit;
cursor: pointer;
transition:
border-color 160ms ease,
background-color 160ms ease,
transform 160ms ease;
}
.topnav a:hover,
.topnav a.active,
.login-link:hover,
button:hover {
background: rgba(143, 79, 31, 0.08);
border-color: rgba(143, 79, 31, 0.18);
transform: translateY(-1px);
}
.topnav a.active {
background: linear-gradient(135deg, rgba(143, 79, 31, 0.14), rgba(217, 164, 65, 0.18));
font-weight: 600;
}
.session-panel {
display: flex;
align-items: center;
gap: 0.9rem;
}
.session-panel strong {
display: block;
}
button {
background: var(--brand-deep);
color: #fff7ef;
box-shadow: var(--shadow);
}
button:hover {
background: #472213;
}
.content {
padding: 2rem;
}
@media (max-width: 980px) {
.topbar {
grid-template-columns: 1fr;
justify-items: start;
padding: 1rem 1rem 0.9rem;
}
.topnav {
justify-content: flex-start;
}
.content {
padding: 1rem;
}
}
</style>
{#if isAdminRoute}
<AdminShell>
{@render children()}
</AdminShell>
{:else}
<ClientShell>
{@render children()}
</ClientShell>
{/if}
File diff suppressed because it is too large Load Diff
+35 -15
View File
@@ -1,20 +1,40 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([
api.rawMaterials(),
api.mixes(),
api.productCosts(),
api.scenarios(),
api.dataQuality()
]);
if (!hasStoredClientSession()) {
return {
rawMaterials: [],
mixes: [],
productCosts: [],
scenarios: [],
dataQuality: []
};
}
return {
rawMaterials,
mixes,
productCosts,
scenarios,
dataQuality
};
try {
const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([
api.rawMaterials(),
api.mixes(),
api.productCosts(),
api.scenarios(),
api.dataQuality()
]);
return {
rawMaterials,
mixes,
productCosts,
scenarios,
dataQuality
};
} catch {
return {
rawMaterials: [],
mixes: [],
productCosts: [],
scenarios: [],
dataQuality: []
};
}
}
+381
View File
@@ -0,0 +1,381 @@
<script lang="ts">
import { api } from '$lib/api';
import { adminSession } from '$lib/session';
let { data } = $props();
let email = $state('admin@lean101.local');
let password = $state('lean101-admin');
let isLoggingIn = $state(false);
let loginError = $state('');
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No preview generated';
}
return new Intl.DateTimeFormat('en-NZ', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(value));
}
async function handleLogin(event: SubmitEvent) {
event.preventDefault();
loginError = '';
isLoggingIn = true;
try {
const session = await api.adminLogin(email, password);
adminSession.set(session);
} catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in';
} finally {
isLoggingIn = false;
}
}
const totalUsers = $derived(data.clients.reduce((sum, client) => sum + client.users.length, 0));
const totalFeatures = $derived(data.clients.reduce((sum, client) => sum + client.enabled_feature_count, 0));
</script>
<section class="hero-card">
<div class="hero-copy">
<p class="eyebrow">Lean 101 Admin Panel</p>
<h2>Separate operator login and client access controls from the Hunter Premium Produce workspace.</h2>
<p>Use this admin surface for internal access changes, export validation, and operator-only workflows.</p>
</div>
<div class="hero-stats">
<article>
<span>Managed clients</span>
<strong>{data.clients.length}</strong>
</article>
<article>
<span>Total users</span>
<strong>{totalUsers}</strong>
</article>
<article>
<span>Enabled features</span>
<strong>{totalFeatures}</strong>
</article>
</div>
</section>
{#if !$adminSession}
<section class="signin-card">
<div class="signin-copy">
<p class="eyebrow">Admin Sign-In</p>
<h3>Authenticate here to unlock the admin navigation and client access controls.</h3>
<p>The public client workspace no longer exposes this operator sign-in.</p>
</div>
<form class="signin-form" onsubmit={handleLogin}>
<input bind:value={email} type="email" autocomplete="username" placeholder="Email" />
<input bind:value={password} type="password" autocomplete="current-password" placeholder="Password" />
<button class="primary-button" type="submit" disabled={isLoggingIn}>
{isLoggingIn ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div class="signin-meta">
{#if loginError}
<strong>{loginError}</strong>
{/if}
</div>
</section>
{:else}
<section class="live-banner">
<div>
<p class="eyebrow">Session Active</p>
<h3>{$adminSession.name} is signed in to the Lean 101 Admin Panel.</h3>
<p>Open the client access workspace to manage users, feature flags, and the Power BI export preview.</p>
</div>
<div class="live-actions">
<a class="primary-button" href="/admin/client-access">Open Client Access</a>
<a class="secondary-button" href="/">View Hunter workspace</a>
</div>
</section>
{/if}
<section class="detail-grid">
<article class="surface-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Scope</p>
<h3>What belongs in admin</h3>
</div>
</div>
<div class="bullet-list">
<article>
<strong>Client access control</strong>
<span>Manage new users, existing users, and feature access by client.</span>
</article>
<article>
<strong>Power BI export validation</strong>
<span>Verify the live export payload after each access change.</span>
</article>
<article>
<strong>Operator-only sign-in</strong>
<span>Keep internal authentication separate from the client workspace at `/`.</span>
</article>
</div>
</article>
<article class="surface-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Preview Snapshot</p>
<h3>Current export summary</h3>
<p>Last generated {formatDate(data.exportPreview.generated_at)}</p>
</div>
</div>
<div class="preview-stats">
<article>
<span>Client rows</span>
<strong>{data.exportPreview.client_rows.length}</strong>
</article>
<article>
<span>User rows</span>
<strong>{data.exportPreview.user_rows.length}</strong>
</article>
<article>
<span>Feature rows</span>
<strong>{data.exportPreview.feature_rows.length}</strong>
</article>
</div>
</article>
</section>
<style>
h2,
h3,
p {
margin: 0;
}
.eyebrow {
color: #6e8576;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero-card,
.signin-card,
.live-banner,
.surface-card {
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.84);
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.06);
}
.hero-card,
.signin-card,
.live-banner,
.detail-grid {
margin-bottom: 1.25rem;
}
.hero-card,
.signin-card,
.live-banner,
.surface-card {
padding: 1.25rem;
}
.hero-card,
.hero-stats,
.detail-grid,
.preview-stats {
display: grid;
gap: 1rem;
}
.hero-card {
grid-template-columns: minmax(0, 1.2fr) 0.85fr;
align-items: end;
}
.hero-copy h2 {
margin: 0.35rem 0 0.45rem;
max-width: 16ch;
font-size: clamp(2rem, 3vw, 2.6rem);
line-height: 1.02;
}
.hero-copy p:last-child,
.signin-copy p:last-child,
.live-banner p:last-child,
.bullet-list span,
.preview-stats span,
.card-toolbar p {
color: #5f7266;
}
.hero-stats,
.preview-stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hero-stats article,
.preview-stats article {
padding: 1rem;
border-radius: 1rem;
background: rgba(243, 247, 241, 0.95);
border: 1px solid rgba(34, 54, 45, 0.08);
}
.hero-stats span,
.preview-stats span {
display: block;
font-size: 0.84rem;
}
.hero-stats strong,
.preview-stats strong {
display: block;
margin-top: 0.35rem;
font-size: 1.8rem;
}
.signin-card {
display: grid;
grid-template-columns: 1.2fr 1fr auto;
gap: 1rem;
align-items: center;
}
.signin-copy h3,
.live-banner h3,
.card-toolbar h3 {
margin: 0.28rem 0 0.35rem;
font-size: 1.2rem;
}
.signin-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.signin-form input {
width: 100%;
padding: 0.9rem 0.95rem;
border: 1px solid rgba(34, 54, 45, 0.12);
border-radius: 0.85rem;
background: rgba(248, 251, 249, 0.92);
}
.signin-meta {
display: grid;
gap: 0.35rem;
justify-items: end;
color: #5f7266;
font-size: 0.9rem;
}
.signin-meta strong {
color: #b33636;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.85rem;
padding: 0.85rem 1rem;
font-weight: 600;
text-decoration: none;
}
.primary-button {
border: none;
color: #fff;
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
box-shadow: 0 8px 20px rgba(32, 48, 40, 0.18);
}
.secondary-button {
border: 1px solid rgba(34, 54, 45, 0.12);
color: #203028;
background: rgba(255, 255, 255, 0.9);
}
.primary-button:disabled {
opacity: 0.72;
cursor: wait;
}
.live-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.live-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.detail-grid {
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.9fr);
}
.card-toolbar {
margin-bottom: 1rem;
}
.bullet-list {
display: grid;
gap: 0.9rem;
}
.bullet-list article {
padding: 0.95rem 1rem;
border-radius: 1rem;
background: rgba(243, 247, 241, 0.95);
border: 1px solid rgba(34, 54, 45, 0.08);
}
.bullet-list strong,
.preview-stats strong {
font-weight: 700;
}
.bullet-list span {
display: block;
margin-top: 0.25rem;
}
@media (max-width: 1120px) {
.hero-card,
.signin-card,
.detail-grid,
.hero-stats,
.preview-stats {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.live-banner {
flex-direction: column;
align-items: flex-start;
}
.signin-form {
grid-template-columns: 1fr;
}
}
</style>
+37
View File
@@ -0,0 +1,37 @@
import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
if (!hasStoredAdminSession()) {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
}
};
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(), api.clientAccessExport()]);
return {
clients,
exportPreview
};
} catch {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
}
};
}
}
@@ -0,0 +1,7 @@
<script lang="ts">
import ClientAccessWorkspace from '$lib/components/ClientAccessWorkspace.svelte';
let { data } = $props();
</script>
<ClientAccessWorkspace {data} />
@@ -0,0 +1,37 @@
import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
if (!hasStoredAdminSession()) {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
}
};
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(), api.clientAccessExport()]);
return {
clients,
exportPreview
};
} catch {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
}
};
}
}
@@ -0,0 +1,857 @@
<script lang="ts">
import { api } from '$lib/api';
import type { ClientAccessAccount, ClientAccessFeature, ClientAccessPowerBiExport } from '$lib/types';
let { data } = $props();
let clients = $state<ClientAccessAccount[]>([]);
let exportPreview = $state<ClientAccessPowerBiExport>({
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
clients: []
});
let selectedClientId = $state(0);
let fullName = $state('');
let email = $state('');
let role = $state('viewer');
let status = $state('invited');
let isNewUser = $state(true);
let formError = $state('');
let formSuccess = $state('');
let isSubmitting = $state(false);
let savingUserId = $state<number | null>(null);
let savingFeatureId = $state<number | null>(null);
let previewStatus = $state('Live preview loaded');
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No activity yet';
}
return new Intl.DateTimeFormat('en-NZ', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(value));
}
function initials(value: string) {
return value
.split(' ')
.map((piece) => piece[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
function replaceClient(updatedClient: ClientAccessAccount) {
clients = clients.map((client) => (client.id === updatedClient.id ? updatedClient : client));
}
async function refreshExportPreview() {
exportPreview = await api.clientAccessExport();
previewStatus = `Preview refreshed ${formatDate(exportPreview.generated_at)}`;
}
async function handleCreateUser(event: SubmitEvent) {
event.preventDefault();
formError = '';
formSuccess = '';
if (!selectedClientId) {
formError = 'Select a client before creating a user.';
return;
}
if (!fullName.trim() || !email.trim()) {
formError = 'Name and email are required.';
return;
}
isSubmitting = true;
try {
const updatedClient = await api.createClientUser({
client_account_id: selectedClientId,
full_name: fullName.trim(),
email: email.trim(),
role,
status,
is_new_user: isNewUser
});
replaceClient(updatedClient);
await refreshExportPreview();
fullName = '';
email = '';
role = 'viewer';
status = 'invited';
isNewUser = true;
formSuccess = 'User created and included in the export preview.';
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to create client user';
} finally {
isSubmitting = false;
}
}
async function updateUser(userId: number, payload: { role?: string; status?: string; is_new_user?: boolean }) {
savingUserId = userId;
formError = '';
formSuccess = '';
try {
const updatedClient = await api.updateClientUser(userId, payload);
replaceClient(updatedClient);
await refreshExportPreview();
formSuccess = 'User access updated.';
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to update client user';
} finally {
savingUserId = null;
}
}
async function toggleFeature(feature: ClientAccessFeature) {
savingFeatureId = feature.id;
formError = '';
formSuccess = '';
try {
const updatedClient = await api.updateClientFeature(feature.id, { enabled: !feature.enabled });
replaceClient(updatedClient);
await refreshExportPreview();
formSuccess = `${feature.feature_name} ${feature.enabled ? 'disabled' : 'enabled'}.`;
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to update feature access';
} finally {
savingFeatureId = null;
}
}
$effect(() => {
if (!clients.length && data.clients.length) {
clients = structuredClone(data.clients) as ClientAccessAccount[];
}
if (!exportPreview.generated_at && data.exportPreview.generated_at) {
exportPreview = structuredClone(data.exportPreview) as ClientAccessPowerBiExport;
}
if (!selectedClientId && data.clients[0]) {
selectedClientId = data.clients[0].id;
}
});
const selectedClient = $derived(clients.find((client) => client.id === selectedClientId) ?? clients[0]);
const totalUsers = $derived(clients.reduce((sum, client) => sum + client.users.length, 0));
const totalEnabledFeatures = $derived(clients.reduce((sum, client) => sum + client.enabled_feature_count, 0));
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>
<strong>{clients.length}</strong>
<p>Accounts currently staged in the client app</p>
</article>
<article class="metric-card">
<span>Total Users</span>
<strong>{totalUsers}</strong>
<p>New and existing users across every client</p>
</article>
<article class="metric-card">
<span>Enabled Features</span>
<strong>{totalEnabledFeatures}</strong>
<p>Feature switches currently turned on</p>
</article>
</section>
<section class="workspace-grid">
<article class="surface-card client-list-card">
<div class="card-toolbar">
<div>
<h3>Clients</h3>
<p>Select a client before amending users or feature access.</p>
</div>
</div>
<div class="client-list">
{#each clients as client}
<button
class:selected={client.id === selectedClient?.id}
class="client-row"
type="button"
onclick={() => {
selectedClientId = client.id;
formError = '';
formSuccess = '';
}}
>
<div class="client-row-head">
<span class="client-badge">{client.client_code}</span>
<div>
<strong>{client.name}</strong>
<span>{client.tenant_id}</span>
</div>
</div>
<div class="client-row-meta">
<span class={`status-pill ${client.status === 'active' ? 'positive' : 'neutral'}`}>{client.status}</span>
<small>{client.active_user_count} active users</small>
</div>
</button>
{/each}
</div>
</article>
<article class="surface-card amend-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Selected Client</p>
<h3>{selectedClient?.name ?? 'No client selected'}</h3>
<p>{selectedClient?.powerbi_workspace ?? 'No Power BI workspace assigned yet.'}</p>
</div>
{#if selectedClient}
<span class={`status-pill ${selectedClient.status === 'active' ? 'positive' : 'neutral'}`}>{selectedClient.status}</span>
{/if}
</div>
<div class="client-summary">
<article>
<span>Existing users</span>
<strong>{selectedClient ? selectedClient.users.length - selectedClient.new_user_count : 0}</strong>
</article>
<article>
<span>New users</span>
<strong>{selectedClient?.new_user_count ?? 0}</strong>
</article>
<article>
<span>Enabled features</span>
<strong>{selectedClient?.enabled_feature_count ?? 0}/{selectedClient?.total_feature_count ?? 0}</strong>
</article>
</div>
<form class="create-user-form" onsubmit={handleCreateUser}>
<div class="section-title">
<h4>Add New User</h4>
<span>Creates the user and immediately updates the export preview.</span>
</div>
<div class="form-grid">
<label>
<span>Full name</span>
<input bind:value={fullName} placeholder="Jordan Lee" />
</label>
<label>
<span>Email</span>
<input bind:value={email} type="email" placeholder="jordan.lee@client.example" />
</label>
<label>
<span>Role</span>
<select bind:value={role}>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
</label>
<label>
<span>Status</span>
<select bind:value={status}>
<option value="invited">Invited</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
</label>
</div>
<label class="toggle-row">
<div>
<strong>Mark as new user</strong>
<span>Controls the onboarding signal carried into the export.</span>
</div>
<input bind:checked={isNewUser} type="checkbox" />
</label>
<div class="form-actions">
<button class="primary-button" type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving user...' : 'Create User'}
</button>
{#if formError}
<strong class="message error">{formError}</strong>
{/if}
{#if !formError && formSuccess}
<strong class="message success">{formSuccess}</strong>
{/if}
</div>
</form>
<div class="section-title">
<h4>Existing Users</h4>
<span>Roles, lifecycle state, and new-user status can be amended inline.</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>New User</th>
<th>Last Login</th>
</tr>
</thead>
<tbody>
{#each selectedClient?.users ?? [] as user}
<tr>
<td class="user-cell">
<div class="user-item">
<span class="user-badge">{initials(user.full_name)}</span>
<div>
<strong>{user.full_name}</strong>
<span>{user.email}</span>
</div>
</div>
</td>
<td>
<select
value={user.role}
disabled={savingUserId === user.id}
onchange={(event) =>
updateUser(user.id, { role: (event.currentTarget as HTMLSelectElement).value })}
>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
</td>
<td>
<select
value={user.status}
disabled={savingUserId === user.id}
onchange={(event) =>
updateUser(user.id, { status: (event.currentTarget as HTMLSelectElement).value })}
>
<option value="active">Active</option>
<option value="invited">Invited</option>
<option value="suspended">Suspended</option>
</select>
</td>
<td>
<label class="inline-toggle">
<input
checked={user.is_new_user}
disabled={savingUserId === user.id}
type="checkbox"
onchange={(event) =>
updateUser(user.id, { is_new_user: (event.currentTarget as HTMLInputElement).checked })}
/>
<span>{user.is_new_user ? 'New' : 'Existing'}</span>
</label>
</td>
<td>
<div class="date-block">
<strong>{user.status}</strong>
<span>{formatDate(user.last_login_at)}</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</article>
<article class="surface-card feature-card">
<div class="card-toolbar">
<div>
<h3>Feature Access</h3>
<p>Every client feature can be switched on or off independently.</p>
</div>
</div>
<div class="feature-list">
{#each selectedClient?.features ?? [] as feature}
<article class="feature-row">
<div>
<div class="feature-head">
<strong>{feature.feature_name}</strong>
<span>{feature.feature_group}</span>
</div>
<p>{feature.description}</p>
</div>
<button
class:enabled={feature.enabled}
class="feature-toggle"
type="button"
disabled={savingFeatureId === feature.id}
onclick={() => toggleFeature(feature)}
>
<span>{savingFeatureId === feature.id ? 'Saving...' : feature.enabled ? 'On' : 'Off'}</span>
</button>
</article>
{/each}
</div>
</article>
</section>
<section class="preview-grid">
<article class="surface-card preview-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Power BI Preview</p>
<h3>Export Shape</h3>
<p>{previewStatus}</p>
</div>
<span class="endpoint-pill">GET /api/powerbi/client-access</span>
</div>
<div class="preview-stats">
<article>
<span>Client rows</span>
<strong>{exportPreview.client_rows.length}</strong>
</article>
<article>
<span>User rows</span>
<strong>{exportPreview.user_rows.length}</strong>
</article>
<article>
<span>Feature rows</span>
<strong>{exportPreview.feature_rows.length}</strong>
</article>
</div>
<pre>{previewJson}</pre>
</article>
</section>
<style>
h2,
h3,
h4,
p,
pre {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.metric-row,
.workspace-grid,
.preview-grid {
margin-bottom: 1.25rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 18ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.card-toolbar p,
.client-row span,
.section-title span,
.feature-row p,
.feature-head span,
.date-block span,
.message,
pre {
color: var(--muted);
}
.metric-row,
.workspace-grid,
.preview-stats,
.client-summary,
.form-grid {
display: grid;
gap: 1rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.workspace-grid {
grid-template-columns: 0.78fr 1.5fr 1fr;
align-items: start;
}
.preview-stats,
.client-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card,
.surface-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.35rem;
box-shadow: var(--shadow);
}
.metric-card {
padding: 1.15rem 1.2rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.9rem;
}
.metric-card strong {
display: block;
margin: 0.55rem 0 0.3rem;
font-size: 1.9rem;
font-weight: 700;
}
.surface-card {
padding: 1.2rem;
}
.card-toolbar,
.client-row,
.client-row-head,
.client-row-meta,
.section-title,
.feature-row,
.form-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.card-toolbar,
.section-title {
margin-bottom: 1rem;
}
.card-toolbar h3,
.section-title h4 {
font-weight: 700;
}
.client-list,
.feature-list {
display: grid;
gap: 0.75rem;
}
.client-row {
width: 100%;
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
text-align: left;
cursor: pointer;
}
.client-row.selected {
border-color: #b9dfc6;
background: var(--green-soft);
}
.client-row strong,
.user-item strong,
.feature-head strong,
.date-block strong {
display: block;
font-size: 0.96rem;
}
.client-badge,
.user-badge {
width: 2.45rem;
height: 2.45rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.8rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.client-summary {
margin-bottom: 1rem;
}
.client-summary article,
.preview-stats article {
padding: 0.9rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.client-summary span,
.preview-stats span {
display: block;
margin-bottom: 0.28rem;
color: var(--muted);
font-size: 0.84rem;
}
.client-summary strong,
.preview-stats strong {
font-size: 1.3rem;
font-weight: 700;
}
.create-user-form {
margin-bottom: 1.2rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
label {
display: grid;
gap: 0.4rem;
}
label span,
.toggle-row span {
font-size: 0.84rem;
color: var(--muted);
}
input,
select {
width: 100%;
padding: 0.82rem 0.88rem;
border: 1px solid var(--line-strong);
border-radius: 0.82rem;
background: #fff;
color: var(--text);
}
.toggle-row,
.inline-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.toggle-row {
margin-top: 1rem;
}
.toggle-row input,
.inline-toggle input {
width: auto;
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 0.85rem;
padding: 0.85rem 1rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.2);
font-weight: 600;
cursor: pointer;
}
.primary-button:disabled {
opacity: 0.72;
cursor: wait;
}
.message.error {
color: #b33636;
}
.message.success {
color: var(--green-deep);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0 0.75rem;
}
th,
td {
padding: 1rem;
text-align: left;
white-space: nowrap;
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
tbody td {
background: var(--panel-soft);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
tbody td:first-child {
border-left: 1px solid var(--line);
border-radius: 1rem 0 0 1rem;
}
tbody td:last-child {
border-right: 1px solid var(--line);
border-radius: 0 1rem 1rem 0;
}
.user-cell {
min-width: 19rem;
}
.user-item,
.feature-head {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-pill,
.endpoint-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.neutral {
color: #5a6c63;
background: #edf2ef;
}
.feature-row {
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.feature-toggle {
min-width: 4.6rem;
padding: 0.72rem 0.8rem;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: #fff;
color: #5a6c63;
font-weight: 700;
cursor: pointer;
}
.feature-toggle.enabled {
color: #fff;
border-color: transparent;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
}
.feature-toggle:disabled {
opacity: 0.7;
cursor: wait;
}
.endpoint-pill {
color: #245961;
background: var(--blue-soft);
}
pre {
margin-top: 1rem;
padding: 1rem;
border-radius: 1rem;
background: #18231d;
border: 1px solid #1f3028;
color: #d6e4dc;
overflow: auto;
font-size: 0.82rem;
line-height: 1.55;
max-height: 34rem;
}
@media (max-width: 1220px) {
.workspace-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 960px) {
.metric-row,
.preview-stats,
.client-summary,
.form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.card-toolbar,
.client-row,
.feature-row,
.form-actions,
.section-title {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(307, '/admin/client-access');
}
+360 -75
View File
@@ -1,96 +1,381 @@
<script lang="ts">
let { data } = $props();
let activeMenuId = $state<number | null>(null);
function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return `$${value.toFixed(digits)}`;
}
const warningCount = $derived(data.mixes.reduce((sum, mix) => sum + mix.warnings.length, 0));
const averageCost = $derived(
data.mixes.length
? data.mixes.reduce((sum, mix) => sum + (mix.mix_cost_per_kg ?? 0), 0) / data.mixes.length
: 0
);
</script>
<section class="panel">
<h2>Mix Master</h2>
<p>Recipes are structured as ingredient rows instead of spreadsheet columns.</p>
<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="cards">
{#each data.mixes as mix}
<article class="card">
<div class="card-header">
<div>
<h3>{mix.name}</h3>
<p>{mix.client_name}</p>
</div>
<span>{mix.status}</span>
</div>
<dl>
<div>
<dt>Total Kg</dt>
<dd>{mix.total_mix_kg}</dd>
</div>
<div>
<dt>Total Cost</dt>
<dd>${mix.total_mix_cost.toFixed(2)}</dd>
</div>
<div>
<dt>Cost/Kg</dt>
<dd>{mix.mix_cost_per_kg ? `$${mix.mix_cost_per_kg.toFixed(4)}` : 'N/A'}</dd>
</div>
</dl>
{#if mix.warnings.length}
<ul>
{#each mix.warnings as warning}
<li>{warning}</li>
{/each}
</ul>
{/if}
</article>
{/each}
<div class="intro-actions">
<a class="primary-button" href="/mixes/new">New Mix Worksheet</a>
</div>
</section>
<section class="metric-row">
<article class="metric-card">
<span>Total Mixes</span>
<strong>{data.mixes.length}</strong>
<p>Saved mix definitions</p>
</article>
<article class="metric-card">
<span>Average Cost / Kg</span>
<strong>{currency(averageCost, 4)}</strong>
<p>Across all saved mixes</p>
</article>
<article class="metric-card">
<span>Warnings</span>
<strong>{warningCount}</strong>
<p>Mixes needing review</p>
</article>
</section>
<section class="table-card">
<div class="section-heading">
<div>
<p class="eyebrow">Table View</p>
<h3>Saved mixes</h3>
</div>
<span class="soft-pill">Open any mix to edit</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Mix</th>
<th>Client</th>
<th>Ingredients</th>
<th>Total Kg</th>
<th>Total Cost</th>
<th>Cost / Kg</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{#each data.mixes as mix}
<tr>
<td>
<div class="table-item">
<span class="row-badge">MX</span>
<div>
<strong>{mix.name}</strong>
<span>v{mix.version ?? 1}</span>
</div>
</div>
</td>
<td>{mix.client_name}</td>
<td>{mix.ingredients.length}</td>
<td>{mix.total_mix_kg}</td>
<td>{currency(mix.total_mix_cost)}</td>
<td>{currency(mix.mix_cost_per_kg, 4)}</td>
<td>
<span class={`status-pill ${mix.warnings.length ? 'warning' : 'positive'}`}>{mix.status}</span>
</td>
<td class="menu-cell">
<div class="menu-wrap">
<button class="menu-trigger" type="button" onclick={() => (activeMenuId = activeMenuId === mix.id ? null : mix.id)}>
Actions
</button>
{#if activeMenuId === mix.id}
<div class="menu-panel">
<a href={`/mixes/${mix.id}`}>Edit worksheet</a>
<a href={`/mixes/${mix.id}`}>Open live cost view</a>
</div>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<style>
.panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 1rem;
}
.card {
background: rgba(255, 251, 244, 0.82);
border-radius: 1.2rem;
padding: 1.1rem;
border: 1px solid rgba(91, 69, 40, 0.12);
}
.card-header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: start;
}
h2,
h3,
p,
dl,
dd {
p {
margin: 0;
}
dl {
.eyebrow {
color: #7f8e85;
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.metric-row,
.table-card {
margin-bottom: 1.12rem;
}
.page-intro,
.metric-card,
.table-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.16rem;
box-shadow: var(--shadow);
}
.page-intro,
.table-card {
padding: 1.08rem;
}
.page-intro,
.section-heading,
.intro-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.68rem;
}
.page-intro {
align-items: end;
}
.page-intro h2 {
margin: 0.3rem 0 0.4rem;
font-size: clamp(1.56rem, 3vw, 2.02rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.table-item span:last-child {
color: var(--muted);
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.82rem;
padding: 0.74rem 0.9rem;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.18);
}
.metric-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-top: 1rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
}
dt {
color: #5f5245;
font-size: 0.85rem;
.metric-card {
padding: 1.04rem 1.08rem;
}
ul {
margin: 1rem 0 0;
padding-left: 1rem;
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.84rem;
}
.metric-card strong {
display: block;
margin: 0.48rem 0 0.26rem;
font-size: 1.72rem;
font-weight: 700;
}
.section-heading {
align-items: flex-start;
margin-bottom: 0.9rem;
}
.section-heading h3 {
font-size: 1.02rem;
font-weight: 700;
}
.soft-pill {
padding: 0.42rem 0.72rem;
border-radius: 999px;
color: var(--green-deep);
background: var(--green-soft);
font-size: 0.82rem;
font-weight: 600;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0 0.54rem;
}
th,
td {
padding: 0.88rem 0.92rem;
text-align: left;
white-space: nowrap;
}
th {
color: var(--muted);
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
tbody td {
background: var(--panel-soft);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
tbody td:first-child {
border-left: 1px solid var(--line);
border-radius: 0.92rem 0 0 0.92rem;
}
tbody td:last-child {
border-right: 1px solid var(--line);
border-radius: 0 0.92rem 0.92rem 0;
}
.table-item {
display: flex;
align-items: center;
gap: 0.74rem;
}
.table-item strong {
display: block;
font-size: 0.94rem;
}
.table-item span:last-child {
display: block;
margin-top: 0.15rem;
font-size: 0.8rem;
}
.row-badge {
width: 2.04rem;
height: 2.04rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.72rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.05em;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.38rem 0.7rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
text-transform: capitalize;
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.warning {
color: #a9681d;
background: #fff6e6;
}
.menu-cell {
width: 1%;
}
.menu-wrap {
position: relative;
}
.menu-trigger {
border: 1px solid var(--line-strong);
border-radius: 0.76rem;
padding: 0.6rem 0.74rem;
color: #304038;
background: #fff;
font-weight: 600;
cursor: pointer;
}
.menu-panel {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
z-index: 10;
min-width: 10rem;
display: grid;
gap: 0.18rem;
padding: 0.32rem;
border: 1px solid var(--line);
border-radius: 0.84rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.1);
}
.menu-panel a {
padding: 0.64rem 0.72rem;
border-radius: 0.7rem;
}
.menu-panel a:hover {
background: var(--panel-soft);
}
@media (max-width: 980px) {
.metric-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.page-intro,
.section-heading {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+16 -4
View File
@@ -1,8 +1,20 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
return {
mixes: await api.mixes()
};
}
if (!hasStoredClientSession()) {
return {
mixes: []
};
}
try {
return {
mixes: await api.mixes()
};
} catch {
return {
mixes: []
};
}
}
@@ -0,0 +1,7 @@
<script lang="ts">
import MixWorkspace from '$lib/components/MixWorkspace.svelte';
let { data } = $props();
</script>
<MixWorkspace rawMaterials={data.rawMaterials} initialMix={data.mix} />
+29
View File
@@ -0,0 +1,29 @@
import { error } from '@sveltejs/kit';
import { api } from '$lib/api';
import { hasStoredClientSession } from '$lib/session';
export async function load({ params }) {
const mixId = Number(params.id);
if (!Number.isFinite(mixId)) {
throw error(404, 'Mix not found');
}
if (!hasStoredClientSession()) {
return {
mix: null,
rawMaterials: []
};
}
try {
const [mix, rawMaterials] = await Promise.all([api.mix(mixId), api.rawMaterials()]);
return {
mix,
rawMaterials
};
} catch {
throw error(404, 'Mix not found');
}
}
@@ -0,0 +1,7 @@
<script lang="ts">
import MixWorkspace from '$lib/components/MixWorkspace.svelte';
let { data } = $props();
</script>
<MixWorkspace rawMaterials={data.rawMaterials} />
+20
View File
@@ -0,0 +1,20 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
if (!hasStoredClientSession()) {
return {
rawMaterials: []
};
}
try {
return {
rawMaterials: await api.rawMaterials()
};
} catch {
return {
rawMaterials: []
};
}
}
+340 -37
View File
@@ -1,59 +1,362 @@
<script lang="ts">
let { data } = $props();
function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return `$${value.toFixed(digits)}`;
}
function initials(name: string) {
return name
.split(' ')
.map((piece) => piece[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
const rows = $derived(
data.products.map((product) => {
const cost = data.productCosts.find((item) => item.product_id === product.id);
return {
...product,
cost,
health: cost?.warnings.length ? 'Review' : 'Healthy',
healthTone: cost?.warnings.length ? 'warning' : 'positive'
};
})
);
const highestDelivered = $derived(
rows.reduce(
(best, row) =>
(row.cost?.finished_product_delivered ?? 0) > (best.cost?.finished_product_delivered ?? 0) ? row : best,
rows[0]
)
);
const averageDelivered = $derived(
rows.length
? rows.reduce((sum, row) => sum + (row.cost?.finished_product_delivered ?? 0), 0) / rows.length
: 0
);
</script>
<section class="panel">
<h2>Products</h2>
<p>Transparent delivered cost and pricing outputs from backend calculations.</p>
<table>
<thead>
<tr>
<th>Product</th>
<th>Mix</th>
<th>Sale Type</th>
<th>Delivered Cost</th>
<th>Distributor</th>
<th>Wholesale</th>
</tr>
</thead>
<tbody>
{#each data.products as product}
{@const cost = data.productCosts.find((item) => item.product_id === product.id)}
<section class="page-intro">
<div>
<p class="eyebrow">Output Pricing</p>
<h2>Products1</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>
<strong>{rows.length}</strong>
<p>Active finished outputs</p>
</article>
<article class="metric-card">
<span>Highest Delivered Cost</span>
<strong>{currency(highestDelivered?.cost?.finished_product_delivered)}</strong>
<p>{highestDelivered?.name ?? 'No product loaded'}</p>
</article>
<article class="metric-card">
<span>Average Delivered Cost</span>
<strong>{currency(averageDelivered)}</strong>
<p>Across all tracked products</p>
</article>
</section>
<section class="table-card">
<div class="table-toolbar">
<div>
<h3>Product Price Table</h3>
<p>Modern row groups with quick-read badges and healthier spacing.</p>
</div>
<button type="button">Pricing View</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<td>{product.name}</td>
<td>{product.mix_name}</td>
<td>{product.sale_type}</td>
<td>{cost ? `$${cost.finished_product_delivered.toFixed(2)}` : 'N/A'}</td>
<td>{cost?.distributor_price ? `$${cost.distributor_price.toFixed(2)}` : 'N/A'}</td>
<td>{cost?.wholesale_price ? `$${cost.wholesale_price.toFixed(2)}` : 'N/A'}</td>
<th>Product</th>
<th>Mix</th>
<th>Sale Type</th>
<th>Delivered</th>
<th>Margins</th>
<th>Health</th>
</tr>
{/each}
</tbody>
</table>
</thead>
<tbody>
{#each rows as row}
<tr>
<td class="product-cell">
<div class="product-item">
<span class="product-badge">{initials(row.name)}</span>
<div>
<strong>{row.name}</strong>
<span>{row.client_name}</span>
</div>
</div>
</td>
<td>
<div class="mix-block">
<strong>{row.mix_name}</strong>
<span>{row.unit_of_measure}</span>
</div>
</td>
<td>
<span class="sale-pill">{row.sale_type}</span>
</td>
<td>
<div class="number-block">
<strong>{currency(row.cost?.finished_product_delivered)}</strong>
<span>Delivered cost</span>
</div>
</td>
<td>
<div class="number-block">
<strong>{currency(row.cost?.distributor_price)} / {currency(row.cost?.wholesale_price)}</strong>
<span>Distributor / wholesale</span>
</div>
</td>
<td>
<span class={`status-pill ${row.healthTone}`}>{row.health}</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<style>
.panel {
background: rgba(255, 251, 244, 0.82);
border-radius: 1.2rem;
padding: 1.2rem;
border: 1px solid rgba(91, 69, 40, 0.12);
h2,
h3,
p {
margin: 0;
}
h2,
p {
margin-top: 0;
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.metric-row,
.table-card {
margin-bottom: 1.25rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 18ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.table-toolbar p,
.product-item span,
.mix-block span,
.number-block span {
color: var(--muted);
}
.metric-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.metric-card,
.table-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.3rem;
box-shadow: var(--shadow);
}
.metric-card {
padding: 1.15rem 1.2rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.9rem;
}
.metric-card strong {
display: block;
margin: 0.55rem 0 0.3rem;
font-size: 1.9rem;
font-weight: 700;
}
.table-card {
padding: 1.2rem;
}
.table-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.table-toolbar h3 {
font-size: 1.15rem;
font-weight: 700;
}
.table-toolbar button {
padding: 0.72rem 0.9rem;
border: 1px solid var(--line-strong);
border-radius: 0.85rem;
background: #fff;
color: #304038;
font-weight: 600;
cursor: pointer;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0 0.75rem;
}
th,
td {
padding: 1rem;
text-align: left;
padding: 0.85rem 0.4rem;
border-bottom: 1px solid rgba(91, 69, 40, 0.1);
white-space: nowrap;
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
tbody td {
background: var(--panel-soft);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
tbody td:first-child {
border-left: 1px solid var(--line);
border-radius: 1rem 0 0 1rem;
}
tbody td:last-child {
border-right: 1px solid var(--line);
border-radius: 0 1rem 1rem 0;
}
.product-cell {
min-width: 20rem;
}
.product-item,
.mix-block,
.number-block {
display: flex;
align-items: center;
gap: 0.8rem;
}
.product-item strong,
.mix-block strong,
.number-block strong {
display: block;
font-size: 0.96rem;
}
.product-item span:last-child,
.mix-block span,
.number-block span {
display: block;
margin-top: 0.18rem;
font-size: 0.82rem;
}
.product-badge {
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.85rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.mix-block,
.number-block {
display: grid;
gap: 0.1rem;
}
.sale-pill,
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 0.42rem 0.75rem;
font-size: 0.84rem;
font-weight: 600;
}
.sale-pill {
color: #245961;
background: var(--blue-soft);
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.warning {
color: #a9681d;
background: #fff6e6;
}
@media (max-width: 960px) {
.metric-row {
grid-template-columns: 1fr;
}
.table-toolbar {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+20 -6
View File
@@ -1,10 +1,24 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
const [products, productCosts] = await Promise.all([api.products(), api.productCosts()]);
return {
products,
productCosts
};
}
if (!hasStoredClientSession()) {
return {
products: [],
productCosts: []
};
}
try {
const [products, productCosts] = await Promise.all([api.products(), api.productCosts()]);
return {
products,
productCosts
};
} catch {
return {
products: [],
productCosts: []
};
}
}
+435 -254
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { api } from '$lib/api';
import { operatorSession } from '$lib/session';
import type { Mix, Product, ProductCostBreakdown } from '$lib/types';
import { clientSession } from '$lib/session';
import type { Mix, Product, ProductCostBreakdown, RawMaterial } from '$lib/types';
let { data } = $props();
@@ -21,6 +21,18 @@
return `$${value.toFixed(digits)}`;
}
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No date';
}
return new Intl.DateTimeFormat('en-NZ', {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(value));
}
function getImpactedMixes(materialId: number): Mix[] {
return data.mixes.filter((mix: Mix) => mix.ingredients.some((ingredient) => ingredient.raw_material_id === materialId));
}
@@ -107,28 +119,49 @@
pendingMaterialId = null;
}
}
const totalSpend = $derived(
data.rawMaterials.reduce(
(sum: number, material: RawMaterial) => sum + (material.current_price?.market_value ?? 0),
0
)
);
const averageWaste = $derived(
data.rawMaterials.length
? data.rawMaterials.reduce(
(sum: number, material: RawMaterial) => sum + (material.current_price?.waste_percentage ?? 0),
0
) / data.rawMaterials.length
: 0
);
const latestEffectiveDate = $derived(
[...data.rawMaterials]
.map((material: RawMaterial) => material.current_price?.effective_date)
.filter(Boolean)
.sort()
.at(-1) ?? null
);
const activeMaterials = $derived(data.rawMaterials.filter((material: RawMaterial) => material.status === 'active'));
</script>
{#if !$operatorSession}
<section class="locked-panel">
<p class="eyebrow">Operator access required</p>
<h1>Sign in from the homepage before managing raw materials.</h1>
<p>This page is the input maintenance area for Mix Master and downstream product pricing.</p>
<a href="/">Return to login</a>
{#if !$clientSession}
<section class="locked-card">
<p class="eyebrow">Client Access Required</p>
<h2>Sign in on the Hunter Premium Produce home page before viewing raw material pricing.</h2>
<p>This workflow updates source inputs and pushes new values through mix and product calculations.</p>
<a href="/">Return to sign-in</a>
</section>
{:else}
<section class="page-header">
<section class="page-intro">
<div>
<p class="eyebrow">Raw material manager</p>
<h1>Maintain input costs and watch the costing model update downstream.</h1>
<p>
Every active price version feeds the existing mix calculation engine. Save a new price, then review the
impacted mixes and finished product outputs below.
</p>
<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="header-status">
<span>{$operatorSession.email}</span>
<strong>{data.rawMaterials.length} materials under control</strong>
<div class="intro-chip">
<span>{$clientSession.email}</span>
<strong>{activeMaterials.length} active materials</strong>
</div>
</section>
@@ -140,13 +173,34 @@
<p class="feedback error">{errorMessage}</p>
{/if}
<section class="metric-row">
<article class="metric-card">
<span>Total Spend Tracked</span>
<strong>{currency(totalSpend)}</strong>
<p>Across current market values</p>
</article>
<article class="metric-card">
<span>Average Waste</span>
<strong>{(averageWaste * 100).toFixed(1)}%</strong>
<p>Current blended input loss</p>
</article>
<article class="metric-card">
<span>Latest Price Update</span>
<strong>{formatDate(latestEffectiveDate)}</strong>
<p>Most recent effective date on file</p>
</article>
</section>
<section class="top-grid">
<article class="surface-card">
<div class="panel-heading">
<article class="surface-card form-card">
<div class="section-heading">
<div>
<p class="eyebrow">Add raw material</p>
<h2>Create a new tracked input</h2>
<p class="eyebrow">Create Input</p>
<h3>Add a new raw material</h3>
</div>
<span class="soft-pill">Live costing source</span>
</div>
<form class="material-form" onsubmit={handleCreateMaterial}>
@@ -196,100 +250,116 @@
</label>
</div>
<label>
Material notes
<textarea name="notes" rows="3"></textarea>
</label>
<div class="form-grid single">
<label>
Material notes
<textarea name="notes" rows="3"></textarea>
</label>
<label>
Price notes
<textarea name="price_notes" rows="2"></textarea>
</label>
<label>
Price notes
<textarea name="price_notes" rows="3"></textarea>
</label>
</div>
<button type="submit" disabled={isCreating}>
<button class="primary-button" type="submit" disabled={isCreating}>
{isCreating ? 'Creating material...' : 'Create raw material'}
</button>
</form>
</article>
<article class="surface-card">
<div class="panel-heading">
<div>
<p class="eyebrow">Downstream view</p>
<h2>Current mix and product snapshot</h2>
<div class="summary-stack">
<article class="surface-card">
<div class="section-heading">
<div>
<p class="eyebrow">Downstream Snapshot</p>
<h3>Mixes affected by current inputs</h3>
</div>
</div>
</div>
<div class="snapshot-list">
<article>
<h3>Mix Master</h3>
<ul>
{#each data.mixes as mix}
<li>
<div class="mini-list">
{#each data.mixes as mix}
<article>
<div>
<strong>{mix.name}</strong>
<span>{currency(mix.mix_cost_per_kg, 4)} / kg</span>
</li>
{/each}
</ul>
</article>
<span>{mix.client_name}</span>
</div>
<strong>{currency(mix.mix_cost_per_kg, 4)} / kg</strong>
</article>
{/each}
</div>
</article>
<article>
<h3>Finished products</h3>
<ul>
{#each data.productCosts as row}
<li>
<article class="surface-card">
<div class="section-heading">
<div>
<p class="eyebrow">Product Exposure</p>
<h3>Finished outputs linked to live pricing</h3>
</div>
</div>
<div class="mini-list">
{#each data.productCosts as row}
<article>
<div>
<strong>{row.product_name}</strong>
<span>{currency(row.finished_product_delivered)}</span>
</li>
{/each}
</ul>
</article>
</div>
</article>
<span>{row.warnings.length ? 'Check warnings' : 'Stable pricing'}</span>
</div>
<strong>{currency(row.finished_product_delivered)}</strong>
</article>
{/each}
</div>
</article>
</div>
</section>
<section class="material-list">
<section class="materials-list">
{#each data.rawMaterials as material}
{@const impactedMixes = getImpactedMixes(material.id)}
{@const impactedProducts = getImpactedProducts(material.id)}
<article class="surface-card material-card">
<div class="panel-heading">
<div>
<p class="eyebrow">Tracked input</p>
<h2>{material.name}</h2>
<p class="subtle">
{material.supplier || 'Supplier not set'} · {material.unit_of_measure} · {material.kg_per_unit} kg per
unit
</p>
<div class="material-header">
<div class="material-title">
<span class={`material-icon ${material.status === 'active' ? 'active' : 'muted'}`}>RM</span>
<div>
<h3>{material.name}</h3>
<p>{material.supplier || 'Supplier not set'} · {material.unit_of_measure} · {material.kg_per_unit} kg per unit</p>
</div>
</div>
<span class:inactive={material.status !== 'active'} class="status-pill">{material.status}</span>
<span class={`status-pill ${material.status === 'active' ? 'positive' : 'neutral'}`}>{material.status}</span>
</div>
<div class="material-grid">
<section class="detail-panel">
<div class="detail-row">
<span>Current market value</span>
<section class="stats-grid">
<article>
<span>Market value</span>
<strong>{currency(material.current_price?.market_value)}</strong>
</div>
<div class="detail-row">
<span>Current waste</span>
</article>
<article>
<span>Waste</span>
<strong>
{material.current_price ? `${(material.current_price.waste_percentage * 100).toFixed(1)}%` : 'N/A'}
</strong>
</div>
<div class="detail-row">
</article>
<article>
<span>Cost per kg</span>
<strong>{currency(material.current_price?.cost_per_kg, 4)}</strong>
</div>
<div class="detail-row">
</article>
<article>
<span>Effective date</span>
<strong>{material.current_price?.effective_date ?? 'N/A'}</strong>
</div>
<strong>{formatDate(material.current_price?.effective_date)}</strong>
</article>
</section>
<form class="price-form" onsubmit={(event) => handleAddPrice(event, material.id)}>
<h3>Record a new price version</h3>
<form class="price-card" onsubmit={(event) => handleAddPrice(event, material.id)}>
<div class="section-heading">
<div>
<p class="eyebrow">New Version</p>
<h4>Record a fresh price</h4>
</div>
</div>
<div class="form-grid compact">
<label>
@@ -313,42 +383,56 @@
<textarea name="notes" rows="2"></textarea>
</label>
<button type="submit" disabled={pendingMaterialId === material.id}>
<button class="primary-button" type="submit" disabled={pendingMaterialId === material.id}>
{pendingMaterialId === material.id ? 'Saving price...' : 'Save price version'}
</button>
</form>
</div>
<div class="impact-grid">
<section class="impact-panel">
<h3>Impacted mixes</h3>
<section class="impact-card">
<div class="impact-heading">
<h4>Impacted mixes</h4>
<span>{impactedMixes.length}</span>
</div>
{#if impactedMixes.length}
<ul>
<div class="impact-list">
{#each impactedMixes as mix}
<li>
<strong>{mix.name}</strong>
<span>{currency(mix.mix_cost_per_kg, 4)} / kg</span>
</li>
<article>
<div>
<strong>{mix.name}</strong>
<span>{mix.client_name}</span>
</div>
<strong>{currency(mix.mix_cost_per_kg, 4)} / kg</strong>
</article>
{/each}
</ul>
</div>
{:else}
<p class="empty">No mix currently references this material.</p>
<p class="empty">No active mix currently references this material.</p>
{/if}
</section>
<section class="impact-panel">
<h3>Impacted products</h3>
<section class="impact-card">
<div class="impact-heading">
<h4>Impacted products</h4>
<span>{impactedProducts.length}</span>
</div>
{#if impactedProducts.length}
<ul>
<div class="impact-list">
{#each impactedProducts as product}
<li>
<strong>{product.name}</strong>
<span>{currency(product.deliveredCost?.finished_product_delivered)}</span>
</li>
<article>
<div>
<strong>{product.name}</strong>
<span>{product.mix_name}</span>
</div>
<strong>{currency(product.deliveredCost?.finished_product_delivered)}</strong>
</article>
{/each}
</ul>
</div>
{:else}
<p class="empty">No product currently depends on this material.</p>
<p class="empty">No finished product currently depends on this material.</p>
{/if}
</section>
</div>
@@ -358,96 +442,115 @@
{/if}
<style>
h1,
h2,
h3,
h4,
p {
margin: 0;
}
a {
color: var(--brand);
text-decoration: none;
}
.eyebrow {
color: var(--muted);
color: #7f8e85;
font-size: 0.78rem;
letter-spacing: 0.1em;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.locked-panel,
.page-header,
.surface-card,
.feedback {
background: rgba(255, 250, 241, 0.82);
.locked-card,
.page-intro,
.feedback,
.metric-card,
.surface-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.5rem;
border-radius: 1.35rem;
box-shadow: var(--shadow);
}
.locked-panel,
.page-header,
.surface-card {
padding: 1.5rem;
.locked-card,
.page-intro,
.feedback,
.metric-row,
.top-grid,
.materials-list {
margin-bottom: 1.25rem;
}
.locked-panel {
.locked-card,
.page-intro,
.surface-card {
padding: 1.2rem;
}
.locked-card {
display: grid;
gap: 0.75rem;
gap: 0.7rem;
max-width: 42rem;
}
.page-header {
.locked-card h2,
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
font-size: clamp(1.7rem, 3vw, 2.25rem);
font-weight: 700;
}
.locked-card p:last-of-type,
.page-intro p:last-child,
.metric-card p,
.intro-chip span,
.mini-list span,
.material-title p,
.stats-grid span,
.impact-list span,
.empty {
color: var(--muted);
}
.locked-card a {
color: var(--green-deep);
font-weight: 600;
}
.page-intro {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.page-header p:last-child,
.subtle,
.empty,
.detail-row span,
.snapshot-list li span {
color: var(--muted);
.intro-chip {
display: grid;
gap: 0.25rem;
padding: 0.95rem 1rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.page-header h1 {
margin: 0.35rem 0 0.55rem;
font-size: clamp(1.8rem, 4vw, 3rem);
max-width: 16ch;
line-height: 1;
}
.header-status {
text-align: right;
}
.header-status span {
display: block;
color: var(--muted);
margin-bottom: 0.35rem;
.intro-chip strong {
font-size: 1rem;
}
.feedback {
padding: 0.95rem 1.1rem;
margin: 0 0 1rem;
padding: 0.95rem 1rem;
font-weight: 600;
}
.feedback.success {
border-color: rgba(44, 106, 66, 0.2);
color: #245838;
color: var(--green-deep);
border-color: #d8ecdf;
background: #f6fcf8;
}
.feedback.error {
border-color: rgba(163, 48, 29, 0.22);
color: #8d2b1f;
color: #a03737;
border-color: #f0d9d9;
background: #fff8f8;
}
.metric-row,
.top-grid,
.material-grid,
.impact-grid {
@@ -455,21 +558,69 @@
gap: 1rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric-card {
padding: 1.15rem 1.2rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.9rem;
}
.metric-card strong {
display: block;
margin: 0.55rem 0 0.3rem;
font-size: 1.9rem;
font-weight: 700;
}
.top-grid {
grid-template-columns: 1.2fr 0.8fr;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.85fr);
}
.summary-stack {
display: grid;
gap: 1rem;
}
.section-heading,
.material-header,
.impact-heading,
.mini-list article,
.impact-list article {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.section-heading {
margin-bottom: 1rem;
}
.panel-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
.section-heading h3,
.material-header h3,
.impact-heading h4 {
font-size: 1.12rem;
font-weight: 700;
}
.soft-pill {
padding: 0.48rem 0.8rem;
border-radius: 999px;
color: var(--green-deep);
background: var(--green-soft);
font-size: 0.86rem;
font-weight: 600;
}
.material-form,
.price-form {
.price-card {
display: grid;
gap: 1rem;
}
@@ -477,7 +628,11 @@
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
gap: 0.85rem;
}
.form-grid.single {
grid-template-columns: 1fr;
}
.form-grid.compact {
@@ -487,147 +642,173 @@
label {
display: grid;
gap: 0.35rem;
color: #53645b;
font-size: 0.9rem;
font-weight: 600;
color: var(--muted);
}
input,
textarea,
select,
button {
font: inherit;
}
input,
textarea,
select {
width: 100%;
padding: 0.85rem 0.95rem;
border-radius: 1rem;
border: 1px solid rgba(90, 45, 24, 0.16);
background: rgba(255, 255, 255, 0.85);
padding: 0.9rem 0.95rem;
border: 1px solid var(--line-strong);
border-radius: 0.95rem;
background: var(--panel-soft);
color: var(--text);
}
button {
padding: 0.95rem 1.1rem;
border: none;
border-radius: 1rem;
background: linear-gradient(135deg, var(--brand-deep), var(--brand));
color: #fff7ef;
font-weight: 700;
cursor: pointer;
.primary-button {
justify-self: start;
padding: 0.85rem 1rem;
border: none;
border-radius: 0.9rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.18);
font-weight: 600;
cursor: pointer;
}
button:disabled {
.primary-button:disabled {
opacity: 0.7;
cursor: wait;
}
.snapshot-list {
display: grid;
gap: 1rem;
}
.snapshot-list article {
padding: 1rem;
border-radius: 1rem;
background: rgba(143, 79, 31, 0.06);
}
.snapshot-list ul,
.impact-panel ul {
list-style: none;
padding: 0;
margin: 0.9rem 0 0;
.mini-list,
.impact-list,
.materials-list {
display: grid;
gap: 0.8rem;
}
.snapshot-list li,
.impact-panel li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.material-list {
display: grid;
gap: 1rem;
.mini-list article,
.impact-list article {
padding: 0.95rem 1rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.material-card {
display: grid;
gap: 1rem;
gap: 1.1rem;
}
.status-pill {
padding: 0.45rem 0.8rem;
border-radius: 999px;
background: rgba(44, 106, 66, 0.12);
color: #245838;
font-weight: 700;
text-transform: capitalize;
}
.status-pill.inactive {
background: rgba(143, 79, 31, 0.1);
color: var(--brand-deep);
}
.material-grid {
grid-template-columns: 0.75fr 1.25fr;
}
.detail-panel,
.impact-panel {
padding: 1rem;
border-radius: 1rem;
background: rgba(143, 79, 31, 0.06);
}
.detail-panel {
display: grid;
.material-title {
display: flex;
align-items: center;
gap: 0.85rem;
}
.detail-row {
display: flex;
.material-icon {
width: 2.4rem;
height: 2.4rem;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
justify-content: center;
border-radius: 0.85rem;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.material-icon.active {
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
}
.material-icon.muted {
color: #55685f;
background: #e9efeb;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
text-transform: capitalize;
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.neutral {
color: #5a6c63;
background: #edf2ef;
}
.material-grid {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.stats-grid article,
.price-card,
.impact-card {
padding: 1rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.stats-grid strong {
display: block;
margin-top: 0.35rem;
font-size: 1.1rem;
font-weight: 700;
}
.impact-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 1100px) {
.impact-heading {
margin-bottom: 0.9rem;
}
.impact-heading span {
color: var(--muted);
font-size: 0.92rem;
font-weight: 600;
}
@media (max-width: 1180px) {
.metric-row,
.top-grid,
.material-grid,
.impact-grid {
.impact-grid,
.stats-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.page-header,
.panel-heading,
.snapshot-list li,
.impact-panel li,
.detail-row {
@media (max-width: 820px) {
.page-intro,
.section-heading,
.material-header,
.impact-heading,
.mini-list article,
.impact-list article {
flex-direction: column;
align-items: start;
align-items: flex-start;
}
.form-grid,
.form-grid.compact {
grid-template-columns: 1fr;
}
.header-status {
text-align: left;
}
}
</style>
+31 -12
View File
@@ -1,17 +1,36 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
const [rawMaterials, mixes, products, productCosts] = await Promise.all([
api.rawMaterials(),
api.mixes(),
api.products(),
api.productCosts()
]);
if (!hasStoredClientSession()) {
return {
rawMaterials: [],
mixes: [],
products: [],
productCosts: []
};
}
return {
rawMaterials,
mixes,
products,
productCosts
};
try {
const [rawMaterials, mixes, products, productCosts] = await Promise.all([
api.rawMaterials(),
api.mixes(),
api.products(),
api.productCosts()
]);
return {
rawMaterials,
mixes,
products,
productCosts
};
} catch {
return {
rawMaterials: [],
mixes: [],
products: [],
productCosts: []
};
}
}
+263 -40
View File
@@ -1,65 +1,288 @@
<script lang="ts">
let { data } = $props();
const scenarioRows = $derived(
data.scenarios.map((scenario) => ({
...scenario,
overrideKeys: Object.keys(scenario.overrides ?? {})
}))
);
const approvedCount = $derived(scenarioRows.filter((scenario) => scenario.status === 'approved').length);
</script>
<section class="panel">
<h2>Scenarios</h2>
<p>Simulation workspaces for raw cost, freight, process, and margin changes.</p>
<div class="scenario-list">
{#each data.scenarios as scenario}
<article>
<header>
<div>
<h3>{scenario.name}</h3>
<p>{scenario.description ?? 'No description'}</p>
</div>
<span>{scenario.status}</span>
</header>
<pre>{JSON.stringify(scenario.overrides, null, 2)}</pre>
</article>
{/each}
<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>
<strong>{scenarioRows.length}</strong>
<p>Saved planning workspaces</p>
</article>
<article class="metric-card">
<span>Approved</span>
<strong>{approvedCount}</strong>
<p>Ready for pricing reference</p>
</article>
<article class="metric-card">
<span>Overrides In Use</span>
<strong>{scenarioRows.reduce((sum, row) => sum + row.overrideKeys.length, 0)}</strong>
<p>Total override keys across all scenarios</p>
</article>
</section>
<section class="scenario-list">
{#each scenarioRows as scenario}
<article class="surface-card">
<div class="scenario-header">
<div>
<p class="eyebrow">Scenario</p>
<h3>{scenario.name}</h3>
<p>{scenario.description ?? 'No description provided yet.'}</p>
</div>
<span class={`status-pill ${scenario.status === 'approved' ? 'positive' : 'neutral'}`}>{scenario.status}</span>
</div>
<div class="scenario-grid">
<section class="detail-card">
<div class="detail-row">
<span>Override count</span>
<strong>{scenario.overrideKeys.length}</strong>
</div>
<div class="detail-row">
<span>Primary state</span>
<strong>{scenario.status}</strong>
</div>
</section>
<section class="detail-card">
<p class="eyebrow">Override Keys</p>
{#if scenario.overrideKeys.length}
<div class="chip-list">
{#each scenario.overrideKeys as key}
<span>{key}</span>
{/each}
</div>
{:else}
<p class="empty">No overrides have been defined yet.</p>
{/if}
</section>
</div>
<section class="json-card">
<div class="json-header">
<h4>Scenario payload</h4>
<span>JSON view</span>
</div>
<pre>{JSON.stringify(scenario.overrides, null, 2)}</pre>
</section>
</article>
{/each}
</section>
<style>
.panel {
display: flex;
flex-direction: column;
h2,
h3,
h4,
p,
pre {
margin: 0;
}
.eyebrow {
color: #7f8e85;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.metric-row,
.scenario-list {
margin-bottom: 1.25rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 18ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.scenario-header p:last-child,
.empty,
.json-header span,
pre {
color: var(--muted);
}
.metric-row,
.scenario-grid {
display: grid;
gap: 1rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric-card,
.surface-card,
.detail-card,
.json-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.35rem;
box-shadow: var(--shadow);
}
.metric-card {
padding: 1.15rem 1.2rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.9rem;
}
.metric-card strong {
display: block;
margin: 0.55rem 0 0.3rem;
font-size: 1.9rem;
font-weight: 700;
}
.scenario-list {
display: grid;
gap: 1rem;
}
article {
background: rgba(255, 251, 244, 0.82);
border-radius: 1.2rem;
padding: 1.1rem;
border: 1px solid rgba(91, 69, 40, 0.12);
}
header {
display: flex;
justify-content: space-between;
.surface-card {
padding: 1.2rem;
display: grid;
gap: 1rem;
align-items: start;
}
h2,
h3,
p,
pre {
margin: 0;
.scenario-header,
.detail-row,
.json-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.scenario-header h3 {
margin: 0.3rem 0 0.4rem;
font-size: 1.15rem;
font-weight: 700;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
text-transform: capitalize;
}
.status-pill.positive {
color: var(--green-deep);
background: var(--green-soft);
}
.status-pill.neutral {
color: #5a6c63;
background: #edf2ef;
}
.scenario-grid {
grid-template-columns: 0.7fr 1.3fr;
}
.detail-card,
.json-card {
padding: 1rem;
}
.detail-row + .detail-row {
margin-top: 0.9rem;
}
.detail-row span {
color: var(--muted);
}
.detail-row strong {
font-size: 1rem;
font-weight: 700;
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 0.7rem;
}
.chip-list span {
padding: 0.45rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
color: #365044;
font-size: 0.84rem;
font-weight: 600;
}
.json-header {
margin-bottom: 0.8rem;
}
.json-header h4 {
font-size: 1rem;
font-weight: 700;
}
pre {
margin-top: 0.75rem;
padding: 0.85rem;
border-radius: 0.9rem;
background: rgba(53, 42, 29, 0.08);
padding: 1rem;
border-radius: 1rem;
background: var(--panel-soft);
border: 1px solid var(--line);
overflow: auto;
font-size: 0.85rem;
line-height: 1.55;
}
@media (max-width: 960px) {
.metric-row,
.scenario-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.scenario-header,
.detail-row,
.json-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+16 -4
View File
@@ -1,8 +1,20 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
export async function load() {
return {
scenarios: await api.scenarios()
};
}
if (!hasStoredClientSession()) {
return {
scenarios: []
};
}
try {
return {
scenarios: await api.scenarios()
};
} catch {
return {
scenarios: []
};
}
}