v1.3 - client and admin scaffolding
This commit is contained in:
+114
-15
@@ -1,7 +1,24 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { mockCosts, mockMixes, mockProducts, mockRawMaterials, mockScenarios } from '$lib/mock';
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
mockClientAccess,
|
||||
mockClientAccessExport,
|
||||
mockCosts,
|
||||
mockMixes,
|
||||
mockProducts,
|
||||
mockRawMaterials,
|
||||
mockScenarios
|
||||
} from '$lib/mock';
|
||||
import type {
|
||||
ClientAccessAccount,
|
||||
ClientAccessPowerBiExport,
|
||||
ClientUserCreateInput,
|
||||
ClientUserUpdateInput,
|
||||
LoginResponse,
|
||||
Mix,
|
||||
MixCreateInput,
|
||||
MixIngredientUpdateInput,
|
||||
MixUpdateInput,
|
||||
Product,
|
||||
ProductCostBreakdown,
|
||||
RawMaterial,
|
||||
@@ -9,25 +26,55 @@ import type {
|
||||
RawMaterialPriceCreateInput,
|
||||
Scenario
|
||||
} from '$lib/types';
|
||||
import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
|
||||
|
||||
const API_BASE_URL = env.PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
|
||||
type AuthMode = 'none' | 'client' | 'admin';
|
||||
|
||||
function getToken(auth: AuthMode) {
|
||||
if (!browser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (auth === 'client') {
|
||||
return getStoredClientSession()?.token ?? null;
|
||||
}
|
||||
|
||||
if (auth === 'admin') {
|
||||
return getStoredAdminSession()?.token ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none'): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`);
|
||||
const token = getToken(auth);
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (auth !== 'none') {
|
||||
throw new Error(response.statusText || 'Unauthorized');
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (auth !== 'none') {
|
||||
throw error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit): Promise<T> {
|
||||
async function request<T>(path: string, options: RequestInit, auth: AuthMode = 'none'): Promise<T> {
|
||||
const token = getToken(auth);
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers ?? {})
|
||||
},
|
||||
...options
|
||||
@@ -50,25 +97,77 @@ async function request<T>(path: string, options: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
rawMaterials: () => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials),
|
||||
mixes: () => fetchJson('/api/mixes', mockMixes),
|
||||
products: () => fetchJson<Product[]>('/api/products', mockProducts),
|
||||
productCosts: () => fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts),
|
||||
scenarios: () => fetchJson<Scenario[]>('/api/scenarios', mockScenarios),
|
||||
dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', []),
|
||||
login: (email: string, password: string) =>
|
||||
request<LoginResponse>('/api/auth/login', {
|
||||
rawMaterials: () => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client'),
|
||||
mixes: () => fetchJson('/api/mixes', mockMixes, 'client'),
|
||||
mix: (mixId: number) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client'),
|
||||
products: () => fetchJson<Product[]>('/api/products', mockProducts, 'client'),
|
||||
productCosts: () => fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client'),
|
||||
scenarios: () => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client'),
|
||||
clientAccess: () => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'admin'),
|
||||
clientAccessExport: () => fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'admin'),
|
||||
dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', [], 'client'),
|
||||
clientLogin: (email: string, password: string) =>
|
||||
request<LoginResponse>('/api/auth/client/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
}),
|
||||
adminLogin: (email: string, password: string) =>
|
||||
request<LoginResponse>('/api/auth/admin/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
}),
|
||||
login: (email: string, password: string) =>
|
||||
request<LoginResponse>('/api/auth/client/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
}),
|
||||
createMix: (payload: MixCreateInput) =>
|
||||
request<Mix>('/api/mixes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
updateMix: (mixId: number, payload: MixUpdateInput) =>
|
||||
request<Mix>(`/api/mixes/${mixId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
addMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
|
||||
request<Mix>(`/api/mixes/${mixId}/ingredients`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
updateMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
|
||||
request<Mix>(`/api/mixes/${mixId}/ingredients/${ingredientId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
deleteMixIngredient: (mixId: number, ingredientId: number) =>
|
||||
request<Mix>(`/api/mixes/${mixId}/ingredients/${ingredientId}`, {
|
||||
method: 'DELETE'
|
||||
}, 'client'),
|
||||
createRawMaterial: (payload: RawMaterialCreateInput) =>
|
||||
request<RawMaterial>('/api/raw-materials', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
}, 'client'),
|
||||
addRawMaterialPrice: (rawMaterialId: number, payload: RawMaterialPriceCreateInput) =>
|
||||
request(`/api/raw-materials/${rawMaterialId}/prices`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}, 'client'),
|
||||
createClientUser: (payload: ClientUserCreateInput) =>
|
||||
request<ClientAccessAccount>('/api/client-access/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'admin'),
|
||||
updateClientUser: (userId: number, payload: ClientUserUpdateInput) =>
|
||||
request<ClientAccessAccount>(`/api/client-access/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'admin'),
|
||||
updateClientFeature: (featureId: number, payload: { enabled: boolean }) =>
|
||||
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'admin')
|
||||
};
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { adminSession } from '$lib/session';
|
||||
|
||||
const navigation = [
|
||||
{ href: '/admin', label: 'Overview', shortLabel: 'OV' },
|
||||
{ href: '/admin/client-access', label: 'Client Access', shortLabel: 'CA' }
|
||||
];
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
function matchesRoute(href: string, pathname: string) {
|
||||
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||
}
|
||||
|
||||
function pageTitle(pathname: string) {
|
||||
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Overview';
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((piece) => piece[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
const isProtectedRoute = $derived(page.url.pathname !== '/admin');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle(page.url.pathname)} | Lean 101 Admin Panel</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-sidebar">
|
||||
<a class="admin-brand" href="/admin">
|
||||
<span class="brand-mark">L1</span>
|
||||
<span>Lean 101 Admin Panel</span>
|
||||
</a>
|
||||
|
||||
<p class="admin-copy">
|
||||
Internal workspace for Lean 101 operators managing client access and controlled workspace changes.
|
||||
</p>
|
||||
|
||||
<nav class="admin-nav" aria-label="Admin navigation">
|
||||
{#each navigation as item}
|
||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
|
||||
<span class="nav-icon">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="admin-footer">
|
||||
<a href="/">Open client workspace</a>
|
||||
{#if $adminSession}
|
||||
<button type="button" onclick={() => adminSession.clear()}>Sign out</button>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="admin-main">
|
||||
<header class="admin-topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Admin Area</p>
|
||||
<h1>{pageTitle(page.url.pathname)}</h1>
|
||||
</div>
|
||||
|
||||
{#if $adminSession}
|
||||
<div class="profile-card">
|
||||
<span class="profile-avatar">{initials($adminSession.name)}</span>
|
||||
<div>
|
||||
<strong>{$adminSession.name}</strong>
|
||||
<span>{$adminSession.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="profile-card guest">
|
||||
<span class="profile-avatar">A</span>
|
||||
<div>
|
||||
<strong>Admin sign-in required</strong>
|
||||
<span>Use `/admin` to authenticate</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<main class="admin-content">
|
||||
{#if isProtectedRoute && !$adminSession}
|
||||
<section class="locked-card">
|
||||
<p class="eyebrow">Restricted</p>
|
||||
<h2>Sign in through the Lean 101 Admin Panel to continue.</h2>
|
||||
<p>Client access controls are only available inside the separate admin workspace.</p>
|
||||
<a href="/admin">Go to admin sign-in</a>
|
||||
</section>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(167, 217, 190, 0.22), transparent 34%),
|
||||
linear-gradient(180deg, #f7f8f4 0%, #eef2ea 100%);
|
||||
color: #203028;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem;
|
||||
border-right: 1px solid rgba(34, 54, 45, 0.12);
|
||||
background: rgba(20, 29, 24, 0.96);
|
||||
color: #f4f7f1;
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-mark,
|
||||
.nav-icon,
|
||||
.profile-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.72rem;
|
||||
color: #0f1713;
|
||||
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
|
||||
}
|
||||
|
||||
.admin-copy {
|
||||
margin: 0;
|
||||
color: rgba(244, 247, 241, 0.74);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.admin-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.72rem;
|
||||
padding: 0.82rem 0.78rem;
|
||||
border-radius: 0.9rem;
|
||||
color: rgba(244, 247, 241, 0.88);
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.admin-nav a:hover,
|
||||
.admin-nav a.active {
|
||||
background: rgba(207, 228, 184, 0.16);
|
||||
}
|
||||
|
||||
.admin-nav a.active {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
border-radius: 0.58rem;
|
||||
color: #0f1713;
|
||||
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.admin-footer {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.admin-footer a,
|
||||
.admin-footer button {
|
||||
padding: 0.82rem 0.88rem;
|
||||
border: 1px solid rgba(244, 247, 241, 0.14);
|
||||
border-radius: 0.88rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.4rem;
|
||||
border-bottom: 1px solid rgba(34, 54, 45, 0.1);
|
||||
background: rgba(247, 248, 244, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.18rem;
|
||||
color: #66806e;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.72rem;
|
||||
padding: 0.45rem 0.52rem;
|
||||
border: 1px solid rgba(34, 54, 45, 0.1);
|
||||
border-radius: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
|
||||
}
|
||||
|
||||
.profile-card strong,
|
||||
.profile-card span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-card span {
|
||||
margin-top: 0.14rem;
|
||||
color: #6b7f72;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.guest .profile-avatar {
|
||||
background: linear-gradient(135deg, #c4d0c8 0%, #7b8b80 100%);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
min-width: 0;
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.locked-card {
|
||||
max-width: 42rem;
|
||||
padding: 1.35rem;
|
||||
border: 1px solid rgba(34, 54, 45, 0.1);
|
||||
border-radius: 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.08);
|
||||
}
|
||||
|
||||
.locked-card h2,
|
||||
.locked-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.locked-card h2 {
|
||||
margin-top: 0.35rem;
|
||||
font-size: clamp(1.8rem, 3vw, 2.3rem);
|
||||
}
|
||||
|
||||
.locked-card p:last-of-type {
|
||||
margin-top: 0.45rem;
|
||||
color: #5d7166;
|
||||
}
|
||||
|
||||
.locked-card a {
|
||||
display: inline-flex;
|
||||
margin-top: 1rem;
|
||||
padding: 0.82rem 0.95rem;
|
||||
border-radius: 0.9rem;
|
||||
background: #203028;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(34, 54, 45, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-topbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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 Access Control</p>
|
||||
<h2>Manage client users, feature flags, and Power BI-ready access data from one admin workspace.</h2>
|
||||
<p>The preview stays aligned with the export payload so access changes and reporting stay in sync.</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: 20ch;
|
||||
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: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(34, 54, 45, 0.1);
|
||||
border-radius: 1.35rem;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.06);
|
||||
}
|
||||
|
||||
.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: rgba(248, 251, 249, 0.92);
|
||||
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, #4f8860 0%, #203028 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: rgba(248, 251, 249, 0.92);
|
||||
}
|
||||
|
||||
.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: rgba(248, 251, 249, 0.92);
|
||||
}
|
||||
|
||||
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, #4f8860 0%, #203028 100%);
|
||||
box-shadow: 0 8px 20px rgba(32, 48, 40, 0.16);
|
||||
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: rgba(248, 251, 249, 0.92);
|
||||
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: rgba(248, 251, 249, 0.92);
|
||||
}
|
||||
|
||||
.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, #4f8860 0%, #203028 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,805 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { clientSession } from '$lib/session';
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
type SearchItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
};
|
||||
|
||||
const navigation = [
|
||||
{ href: '/', label: 'Overview', shortLabel: 'OV' },
|
||||
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM' },
|
||||
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM' },
|
||||
{ href: '/products', label: 'Products', shortLabel: 'PR' },
|
||||
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC' }
|
||||
];
|
||||
|
||||
const footerLinks = [
|
||||
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
|
||||
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
|
||||
];
|
||||
|
||||
const searchItems: SearchItem[] = [
|
||||
{
|
||||
href: '/',
|
||||
label: 'Open Hunter Overview',
|
||||
description: 'Jump to the Hunter Premium Produce workspace summary.',
|
||||
keywords: 'hunter premium produce overview dashboard workspace'
|
||||
},
|
||||
{
|
||||
href: '/raw-materials',
|
||||
label: 'Open Raw Materials',
|
||||
description: 'Review live input costs that feed the pricing model.',
|
||||
keywords: 'raw materials pricing inputs costs supplier'
|
||||
},
|
||||
{
|
||||
href: '/mixes',
|
||||
label: 'Open Mix Master',
|
||||
description: 'Browse saved mixes and their costing outputs.',
|
||||
keywords: 'mix master mixes recipes spreadsheet'
|
||||
},
|
||||
{
|
||||
href: '/mixes/new',
|
||||
label: 'Create New Mix',
|
||||
description: 'Start a new costing worksheet for Hunter Premium Produce.',
|
||||
keywords: 'new mix create worksheet hunter premium produce formula'
|
||||
},
|
||||
{
|
||||
href: '/products',
|
||||
label: 'Open Products',
|
||||
description: 'Review delivered product pricing and margins.',
|
||||
keywords: 'products pricing margins delivered outputs'
|
||||
},
|
||||
{
|
||||
href: '/scenarios',
|
||||
label: 'Open Scenarios',
|
||||
description: 'Inspect planning scenarios and overrides.',
|
||||
keywords: 'scenarios sandbox overrides compare planning'
|
||||
}
|
||||
];
|
||||
|
||||
let { children } = $props();
|
||||
const isRootRoute = $derived(page.url.pathname === '/');
|
||||
|
||||
let paletteOpen = $state(false);
|
||||
let paletteQuery = $state('');
|
||||
let quickMenuOpen = $state(false);
|
||||
let paletteInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
function matchesRoute(href: string, pathname: string) {
|
||||
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||
}
|
||||
|
||||
function pageTitle(pathname: string) {
|
||||
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Overview';
|
||||
}
|
||||
|
||||
function pageDescription(pathname: string) {
|
||||
const descriptions: Record<string, string> = {
|
||||
'/': 'Hunter Premium Produce client workspace',
|
||||
'/raw-materials': 'Review source input costs and downstream exposure',
|
||||
'/mixes': 'Browse saved mix worksheets and costing outputs',
|
||||
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce',
|
||||
'/products': 'Track delivered product pricing and margin views',
|
||||
'/scenarios': 'Compare alternate pricing and production assumptions'
|
||||
};
|
||||
|
||||
return descriptions[pathname] ?? 'Hunter Premium Produce client workspace';
|
||||
}
|
||||
|
||||
function openPalette(query = '') {
|
||||
paletteQuery = query;
|
||||
paletteOpen = true;
|
||||
quickMenuOpen = false;
|
||||
}
|
||||
|
||||
async function runSearchItem(item: SearchItem) {
|
||||
paletteOpen = false;
|
||||
paletteQuery = '';
|
||||
await goto(item.href);
|
||||
}
|
||||
|
||||
const filteredSearchItems = $derived(
|
||||
searchItems.filter((item) => {
|
||||
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
|
||||
return haystack.includes(paletteQuery.trim().toLowerCase());
|
||||
})
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
page.url.pathname;
|
||||
quickMenuOpen = false;
|
||||
paletteOpen = false;
|
||||
paletteQuery = '';
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (paletteOpen) {
|
||||
tick().then(() => paletteInput?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isTypingField =
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target?.isContentEditable;
|
||||
|
||||
if ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/')) {
|
||||
event.preventDefault();
|
||||
openPalette();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
paletteOpen = false;
|
||||
quickMenuOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle(page.url.pathname)} | Hunter Premium Produce</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand-row">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">HP</span>
|
||||
<span>Hunter Premium Produce</span>
|
||||
</a>
|
||||
|
||||
<button class="nav-toggle" type="button" aria-label="Navigation options">
|
||||
<span></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="search-box" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
|
||||
<span class="search-icon"></span>
|
||||
<span class="search-placeholder">Search the workspace...</span>
|
||||
<kbd>/</kbd>
|
||||
</button>
|
||||
|
||||
<nav class="nav-list" aria-label="Client navigation">
|
||||
{#each navigation as item}
|
||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
|
||||
<span class="nav-icon">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
{#each footerLinks as item}
|
||||
<a href={item.href}>
|
||||
<span class="nav-icon muted">{item.shortLabel}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main-shell">
|
||||
<header class="topbar">
|
||||
<div class="topbar-copy">
|
||||
<h1>{pageTitle(page.url.pathname)}</h1>
|
||||
<p>{pageDescription(page.url.pathname)}</p>
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
{#if $clientSession}
|
||||
<button class="workspace-chip session-chip" type="button" onclick={() => clientSession.clear()}>
|
||||
<span class="workspace-label">Signed in</span>
|
||||
<strong>{$clientSession.email}</strong>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="workspace-chip">
|
||||
<span class="workspace-label">Client</span>
|
||||
<strong>Sign in required</strong>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="menu-wrap">
|
||||
<button class="action-button" type="button" onclick={() => (quickMenuOpen = !quickMenuOpen)}>
|
||||
Quick Actions
|
||||
<span class:open={quickMenuOpen} class="chevron"></span>
|
||||
</button>
|
||||
|
||||
{#if quickMenuOpen}
|
||||
<div class="menu-panel">
|
||||
<a href="/mixes">Open mix costing</a>
|
||||
<a href="/mixes/new">Create mix worksheet</a>
|
||||
<a href="/products">Review delivered pricing</a>
|
||||
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
{#if !isRootRoute && !$clientSession}
|
||||
<section class="locked-card">
|
||||
<p class="workspace-label">Client Sign-In Required</p>
|
||||
<h2>Sign in on the Hunter Premium Produce home page to unlock workspace data.</h2>
|
||||
<p>The client-facing routes stay empty until a valid client session is active.</p>
|
||||
<a href="/">Return to sign-in</a>
|
||||
</section>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if paletteOpen}
|
||||
<div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}>
|
||||
<div
|
||||
class="palette"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Workspace search"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
paletteOpen = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="palette-input-row">
|
||||
<span class="search-icon"></span>
|
||||
<input bind:this={paletteInput} bind:value={paletteQuery} placeholder="Search pages, workflows, and pricing views..." />
|
||||
<kbd>Esc</kbd>
|
||||
</div>
|
||||
|
||||
<div class="palette-results">
|
||||
{#if filteredSearchItems.length}
|
||||
{#each filteredSearchItems as item}
|
||||
<button class="palette-item" type="button" onclick={() => runSearchItem(item)}>
|
||||
<div>
|
||||
<strong>{item.label}</strong>
|
||||
<span>{item.description}</span>
|
||||
</div>
|
||||
<small>{item.href}</small>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="palette-empty">
|
||||
<strong>No results</strong>
|
||||
<span>Try searching for mixes, products, scenarios, or pricing.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
:global(:root) {
|
||||
--bg: #f4f7f5;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #f8fbf9;
|
||||
--line: #e5ece7;
|
||||
--line-strong: #d9e4dd;
|
||||
--text: #18231d;
|
||||
--muted: #6d7d74;
|
||||
--green: #22a95e;
|
||||
--green-deep: #148249;
|
||||
--green-soft: #eaf8ef;
|
||||
--blue-soft: #eef7ff;
|
||||
--shadow: 0 10px 30px rgba(15, 23, 17, 0.06);
|
||||
}
|
||||
|
||||
:global(html, body) {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(h1, h2, h3, h4, h5, h6) {
|
||||
font-family: Inter, "Segoe UI", sans-serif;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
:global(button),
|
||||
:global(input),
|
||||
:global(select),
|
||||
:global(textarea) {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 228px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding: 0.9rem;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.68rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.68rem;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-mark,
|
||||
.nav-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 0.68rem;
|
||||
}
|
||||
|
||||
.nav-toggle,
|
||||
.action-button,
|
||||
.menu-panel button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
width: 2.05rem;
|
||||
height: 2.05rem;
|
||||
border-radius: 0.68rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav-toggle span,
|
||||
.search-icon,
|
||||
.chevron {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-toggle span,
|
||||
.nav-toggle span::before,
|
||||
.nav-toggle span::after {
|
||||
width: 0.88rem;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 999px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.nav-toggle span::before,
|
||||
.nav-toggle span::after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.nav-toggle span::before {
|
||||
top: -0.28rem;
|
||||
}
|
||||
|
||||
.nav-toggle span::after {
|
||||
top: 0.28rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.64rem;
|
||||
width: 100%;
|
||||
padding: 0.72rem 0.82rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.82rem;
|
||||
background: var(--panel-soft);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: #93a098;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 0.82rem;
|
||||
height: 0.82rem;
|
||||
border: 2px solid #98a59d;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.search-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -0.28rem;
|
||||
bottom: -0.18rem;
|
||||
width: 0.42rem;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: #98a59d;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1rem 0.42rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0.42rem;
|
||||
color: var(--muted);
|
||||
background: #fff;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.nav-list,
|
||||
.sidebar-footer {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.nav-list a,
|
||||
.sidebar-footer a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.68rem;
|
||||
padding: 0.72rem 0.68rem;
|
||||
border-radius: 0.82rem;
|
||||
color: #304038;
|
||||
transition: background-color 160ms ease;
|
||||
}
|
||||
|
||||
.nav-list a:hover,
|
||||
.sidebar-footer a:hover,
|
||||
.nav-list a.active {
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.nav-list a.active {
|
||||
color: var(--green-deep);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 1.56rem;
|
||||
height: 1.56rem;
|
||||
border-radius: 0.56rem;
|
||||
}
|
||||
|
||||
.nav-icon.muted {
|
||||
background: linear-gradient(135deg, #95a39b 0%, #6e7c73 100%);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.main-shell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.9rem;
|
||||
padding: 0.86rem 1.34rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.topbar-copy h1,
|
||||
.topbar-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topbar-copy h1 {
|
||||
font-size: 1.62rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topbar-copy p {
|
||||
margin-top: 0.22rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.68rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.workspace-chip {
|
||||
display: grid;
|
||||
gap: 0.14rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.92rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.session-chip {
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workspace-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workspace-chip strong {
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.62rem;
|
||||
border-radius: 0.88rem;
|
||||
padding: 0.68rem 0.84rem;
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 0.54rem;
|
||||
height: 0.54rem;
|
||||
border-right: 2px solid #7a8c82;
|
||||
border-bottom: 2px solid #7a8c82;
|
||||
transform: rotate(45deg);
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.chevron.open {
|
||||
transform: rotate(-135deg);
|
||||
}
|
||||
|
||||
.menu-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.45rem);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
min-width: 13rem;
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.96rem;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.menu-panel a,
|
||||
.menu-panel button {
|
||||
padding: 0.72rem 0.78rem;
|
||||
border-radius: 0.78rem;
|
||||
color: #304038;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.menu-panel a:hover,
|
||||
.menu-panel button:hover {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
padding: 1.34rem;
|
||||
}
|
||||
|
||||
.locked-card {
|
||||
max-width: 42rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.locked-card h2,
|
||||
.locked-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.locked-card h2 {
|
||||
margin-top: 0.35rem;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
}
|
||||
|
||||
.locked-card p:last-of-type {
|
||||
margin-top: 0.45rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.locked-card a {
|
||||
display: inline-flex;
|
||||
margin-top: 1rem;
|
||||
padding: 0.78rem 0.92rem;
|
||||
border-radius: 0.88rem;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
padding: 8vh 1rem 1rem;
|
||||
background: rgba(11, 18, 14, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.palette {
|
||||
width: min(44rem, 100%);
|
||||
border: 1px solid rgba(217, 228, 221, 0.9);
|
||||
border-radius: 1.2rem;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 17, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.palette-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.palette-input-row input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.palette-results {
|
||||
max-height: 26rem;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.palette-item,
|
||||
.palette-empty {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.88rem 0.92rem;
|
||||
border: none;
|
||||
border-radius: 0.92rem;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.palette-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.palette-item:hover {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.palette-item strong,
|
||||
.palette-empty strong {
|
||||
display: block;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.palette-item span,
|
||||
.palette-empty span,
|
||||
.palette-item small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.palette-item span {
|
||||
display: block;
|
||||
margin-top: 0.18rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.palette-item small {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.palette-empty {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar,
|
||||
.topbar-actions,
|
||||
.action-button {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0.92rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,884 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { clientSession } from '$lib/session';
|
||||
import type { Mix, MixIngredient, RawMaterial } from '$lib/types';
|
||||
|
||||
type DraftIngredient = {
|
||||
id: number | null;
|
||||
raw_material_id: number | null;
|
||||
quantity_kg: number;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
let {
|
||||
rawMaterials,
|
||||
initialMix = null
|
||||
}: {
|
||||
rawMaterials: RawMaterial[];
|
||||
initialMix?: Mix | null;
|
||||
} = $props();
|
||||
const getInitialMix = () => initialMix;
|
||||
|
||||
let savedMix = $state<Mix | null>(getInitialMix());
|
||||
let mixName = $state(getInitialMix()?.name ?? '');
|
||||
let clientName = $state(getInitialMix()?.client_name ?? '');
|
||||
let mixStatus = $state(getInitialMix()?.status ?? 'draft');
|
||||
let mixVersion = $state(getInitialMix()?.version ?? 1);
|
||||
let mixNotes = $state(getInitialMix()?.notes ?? '');
|
||||
let draftIngredients = $state<DraftIngredient[]>([]);
|
||||
let feedback = $state('');
|
||||
let errorMessage = $state('');
|
||||
let isSaving = $state(false);
|
||||
|
||||
function currency(value: number | null | undefined, digits = 2) {
|
||||
if (value === null || value === undefined) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
return `$${value.toFixed(digits)}`;
|
||||
}
|
||||
|
||||
function findRawMaterial(rawMaterialId: number | null) {
|
||||
return rawMaterials.find((material) => material.id === rawMaterialId) ?? null;
|
||||
}
|
||||
|
||||
function rowFromIngredient(ingredient: MixIngredient): DraftIngredient {
|
||||
return {
|
||||
id: ingredient.id,
|
||||
raw_material_id: ingredient.raw_material_id,
|
||||
quantity_kg: ingredient.quantity_kg,
|
||||
notes: ingredient.notes ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyIngredient(): DraftIngredient {
|
||||
return {
|
||||
id: null,
|
||||
raw_material_id: rawMaterials[0]?.id ?? null,
|
||||
quantity_kg: 0,
|
||||
notes: ''
|
||||
};
|
||||
}
|
||||
|
||||
function loadDraftFromMix(mix: Mix | null) {
|
||||
if (!mix) {
|
||||
savedMix = null;
|
||||
mixName = '';
|
||||
clientName = '';
|
||||
mixStatus = 'draft';
|
||||
mixVersion = 1;
|
||||
mixNotes = '';
|
||||
draftIngredients = [createEmptyIngredient()];
|
||||
return;
|
||||
}
|
||||
|
||||
savedMix = mix;
|
||||
mixName = mix.name;
|
||||
clientName = mix.client_name;
|
||||
mixStatus = mix.status;
|
||||
mixVersion = mix.version ?? 1;
|
||||
mixNotes = mix.notes ?? '';
|
||||
draftIngredients = mix.ingredients.length ? mix.ingredients.map(rowFromIngredient) : [createEmptyIngredient()];
|
||||
}
|
||||
|
||||
loadDraftFromMix(getInitialMix());
|
||||
|
||||
function resetDraft() {
|
||||
feedback = '';
|
||||
errorMessage = '';
|
||||
loadDraftFromMix(savedMix);
|
||||
}
|
||||
|
||||
function updateIngredientField(index: number, field: keyof DraftIngredient, value: string | number | null) {
|
||||
draftIngredients = draftIngredients.map((row, rowIndex) =>
|
||||
rowIndex === index
|
||||
? {
|
||||
...row,
|
||||
[field]: value
|
||||
}
|
||||
: row
|
||||
);
|
||||
}
|
||||
|
||||
function addIngredientRow() {
|
||||
draftIngredients = [...draftIngredients, createEmptyIngredient()];
|
||||
}
|
||||
|
||||
function removeIngredientRow(index: number) {
|
||||
draftIngredients = draftIngredients.filter((_, rowIndex) => rowIndex !== index);
|
||||
|
||||
if (!draftIngredients.length) {
|
||||
draftIngredients = [createEmptyIngredient()];
|
||||
}
|
||||
}
|
||||
|
||||
function getCostPerKg(rawMaterialId: number | null) {
|
||||
return findRawMaterial(rawMaterialId)?.current_price?.cost_per_kg ?? null;
|
||||
}
|
||||
|
||||
function getDraftWarnings() {
|
||||
const warnings: string[] = [];
|
||||
const chosen = draftIngredients
|
||||
.map((row) => row.raw_material_id)
|
||||
.filter((rawMaterialId): rawMaterialId is number => rawMaterialId !== null);
|
||||
|
||||
if (!mixName.trim()) {
|
||||
warnings.push('Mix name is required.');
|
||||
}
|
||||
|
||||
if (!clientName.trim()) {
|
||||
warnings.push('Client name is required.');
|
||||
}
|
||||
|
||||
if (!draftIngredients.length || draftIngredients.every((row) => !row.raw_material_id || row.quantity_kg <= 0)) {
|
||||
warnings.push('Add at least one ingredient row with a positive quantity.');
|
||||
}
|
||||
|
||||
if (new Set(chosen).size !== chosen.length) {
|
||||
warnings.push('Each raw material can only appear once in a mix.');
|
||||
}
|
||||
|
||||
draftIngredients.forEach((row, index) => {
|
||||
if (row.raw_material_id === null) {
|
||||
warnings.push(`Row ${index + 1} is missing a raw material.`);
|
||||
}
|
||||
if (row.quantity_kg <= 0) {
|
||||
warnings.push(`Row ${index + 1} must have a quantity greater than zero.`);
|
||||
}
|
||||
if (row.raw_material_id !== null && getCostPerKg(row.raw_material_id) === null) {
|
||||
warnings.push(`Row ${index + 1} has no active raw material price.`);
|
||||
}
|
||||
});
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function getCleanIngredients() {
|
||||
return draftIngredients
|
||||
.filter((row) => row.raw_material_id !== null && row.quantity_kg > 0)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
raw_material_id: row.raw_material_id as number,
|
||||
quantity_kg: Number(row.quantity_kg),
|
||||
notes: row.notes.trim() || null
|
||||
}));
|
||||
}
|
||||
|
||||
async function saveMix() {
|
||||
feedback = '';
|
||||
errorMessage = '';
|
||||
|
||||
const validationWarnings = getDraftWarnings();
|
||||
if (validationWarnings.length) {
|
||||
errorMessage = validationWarnings[0];
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
const cleanIngredients = getCleanIngredients();
|
||||
|
||||
if (!savedMix) {
|
||||
const created = await api.createMix({
|
||||
client_name: clientName.trim(),
|
||||
name: mixName.trim(),
|
||||
status: mixStatus,
|
||||
version: mixVersion,
|
||||
notes: mixNotes.trim() || null,
|
||||
ingredients: cleanIngredients.map((row) => ({
|
||||
raw_material_id: row.raw_material_id,
|
||||
quantity_kg: row.quantity_kg,
|
||||
notes: row.notes
|
||||
}))
|
||||
});
|
||||
|
||||
await invalidateAll();
|
||||
await goto(`/mixes/${created.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await api.updateMix(savedMix.id, {
|
||||
client_name: clientName.trim(),
|
||||
name: mixName.trim(),
|
||||
status: mixStatus,
|
||||
version: mixVersion,
|
||||
notes: mixNotes.trim() || null
|
||||
});
|
||||
|
||||
const originalById = new Map(savedMix.ingredients.map((ingredient) => [ingredient.id, ingredient]));
|
||||
const draftIds = new Set(cleanIngredients.filter((row) => row.id !== null).map((row) => row.id as number));
|
||||
|
||||
const rowsToDelete = savedMix.ingredients.filter((ingredient) => {
|
||||
if (!draftIds.has(ingredient.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const draftRow = cleanIngredients.find((row) => row.id === ingredient.id);
|
||||
return draftRow ? draftRow.raw_material_id !== ingredient.raw_material_id : false;
|
||||
});
|
||||
|
||||
for (const ingredient of rowsToDelete) {
|
||||
await api.deleteMixIngredient(savedMix.id, ingredient.id);
|
||||
}
|
||||
|
||||
const rowsToAdd = cleanIngredients.filter((row) => {
|
||||
if (row.id === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const originalRow = originalById.get(row.id);
|
||||
return originalRow ? originalRow.raw_material_id !== row.raw_material_id : true;
|
||||
});
|
||||
|
||||
for (const row of rowsToAdd) {
|
||||
await api.addMixIngredient(savedMix.id, {
|
||||
raw_material_id: row.raw_material_id,
|
||||
quantity_kg: row.quantity_kg,
|
||||
notes: row.notes
|
||||
});
|
||||
}
|
||||
|
||||
const rowsToPatch = cleanIngredients.filter((row) => {
|
||||
if (row.id === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const originalRow = originalById.get(row.id);
|
||||
if (!originalRow || originalRow.raw_material_id !== row.raw_material_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return originalRow.quantity_kg !== row.quantity_kg || (originalRow.notes ?? null) !== row.notes;
|
||||
});
|
||||
|
||||
for (const row of rowsToPatch) {
|
||||
await api.updateMixIngredient(savedMix.id, row.id as number, {
|
||||
quantity_kg: row.quantity_kg,
|
||||
notes: row.notes
|
||||
});
|
||||
}
|
||||
|
||||
const refreshed = await api.mix(savedMix.id);
|
||||
await invalidateAll();
|
||||
loadDraftFromMix(refreshed);
|
||||
feedback = 'Mix saved with updated ingredient costing.';
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : 'Unable to save mix';
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const draftRows = $derived(
|
||||
draftIngredients.map((row) => {
|
||||
const material = findRawMaterial(row.raw_material_id);
|
||||
const costPerKg = material?.current_price?.cost_per_kg ?? null;
|
||||
const lineCost = costPerKg === null ? null : Number((row.quantity_kg * costPerKg).toFixed(4));
|
||||
|
||||
return {
|
||||
...row,
|
||||
marketValue: material?.current_price?.market_value ?? null,
|
||||
wastePercentage: material?.current_price?.waste_percentage ?? null,
|
||||
costPerKg,
|
||||
lineCost
|
||||
};
|
||||
})
|
||||
);
|
||||
const totalMixKg = $derived(draftRows.reduce((sum, row) => sum + Number(row.quantity_kg || 0), 0));
|
||||
const totalMixCost = $derived(Number(draftRows.reduce((sum, row) => sum + Number(row.lineCost || 0), 0).toFixed(4)));
|
||||
const mixCostPerKg = $derived(totalMixKg > 0 ? Number((totalMixCost / totalMixKg).toFixed(4)) : null);
|
||||
const draftWarnings = $derived(getDraftWarnings());
|
||||
</script>
|
||||
|
||||
{#if !$clientSession}
|
||||
<section class="locked-card">
|
||||
<p class="eyebrow">Client Access Required</p>
|
||||
<h2>Sign in on the Hunter Premium Produce home page before editing mixes.</h2>
|
||||
<p>Mix worksheets use live raw material pricing and save directly into Mix Master.</p>
|
||||
<a href="/">Return to sign-in</a>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">{savedMix ? 'Edit Mix' : 'New Mix'}</p>
|
||||
<h2>{savedMix ? `Editing ${savedMix.name}` : 'Create a new costing worksheet'}</h2>
|
||||
<p>Use ingredient rows like a spreadsheet, with live costing based on market value, waste, and unit conversion.</p>
|
||||
</div>
|
||||
|
||||
<div class="intro-actions">
|
||||
<a class="secondary-button" href="/mixes">Back to table</a>
|
||||
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : savedMix ? 'Save Mix' : 'Create Mix'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if feedback}
|
||||
<p class="feedback success">{feedback}</p>
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="feedback error">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
<section class="metric-row">
|
||||
<article class="metric-card">
|
||||
<span>Live Draft Kg</span>
|
||||
<strong>{totalMixKg.toFixed(2)}</strong>
|
||||
<p>Total quantity in the current worksheet</p>
|
||||
</article>
|
||||
|
||||
<article class="metric-card">
|
||||
<span>Live Draft Cost</span>
|
||||
<strong>{currency(totalMixCost)}</strong>
|
||||
<p>Calculated from current row factors</p>
|
||||
</article>
|
||||
|
||||
<article class="metric-card">
|
||||
<span>Cost / Kg</span>
|
||||
<strong>{currency(mixCostPerKg, 4)}</strong>
|
||||
<p>Current worksheet output</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="editor-grid">
|
||||
<article class="editor-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Worksheet Meta</p>
|
||||
<h3>Mix details</h3>
|
||||
</div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<button class="secondary-button" type="button" onclick={resetDraft}>Reset</button>
|
||||
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : savedMix ? 'Save Mix' : 'Create Mix'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-grid">
|
||||
<label>
|
||||
Mix Name
|
||||
<input bind:value={mixName} placeholder="Hunter Orchard Blend" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Client
|
||||
<input bind:value={clientName} placeholder="Hunter Premium Produce" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Status
|
||||
<select bind:value={mixStatus}>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Version
|
||||
<input bind:value={mixVersion} type="number" min="1" step="1" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="notes-field">
|
||||
Notes
|
||||
<textarea bind:value={mixNotes} rows="3" placeholder="Internal mixing notes, process assumptions, or version comments"></textarea>
|
||||
</label>
|
||||
|
||||
<div class="section-heading spreadsheet-head">
|
||||
<div>
|
||||
<p class="eyebrow">Spreadsheet Rows</p>
|
||||
<h4>Ingredient builder</h4>
|
||||
</div>
|
||||
<button class="secondary-button" type="button" onclick={addIngredientRow}>Add Row</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-wrap">
|
||||
<table class="sheet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raw Material</th>
|
||||
<th>Market Value</th>
|
||||
<th>Waste %</th>
|
||||
<th>Cost / Kg</th>
|
||||
<th>Qty Kg</th>
|
||||
<th>Line Cost</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each draftRows as row, index}
|
||||
<tr>
|
||||
<td>
|
||||
<select
|
||||
value={row.raw_material_id ?? ''}
|
||||
onchange={(event) =>
|
||||
updateIngredientField(
|
||||
index,
|
||||
'raw_material_id',
|
||||
Number((event.currentTarget as HTMLSelectElement).value) || null
|
||||
)}
|
||||
>
|
||||
<option value="">Select material</option>
|
||||
{#each rawMaterials as material}
|
||||
<option value={material.id}>{material.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td>{currency(row.marketValue)}</td>
|
||||
<td>{row.wastePercentage !== null ? `${(row.wastePercentage * 100).toFixed(1)}%` : 'N/A'}</td>
|
||||
<td>{currency(row.costPerKg, 4)}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={row.quantity_kg}
|
||||
oninput={(event) =>
|
||||
updateIngredientField(index, 'quantity_kg', Number((event.currentTarget as HTMLInputElement).value))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>{currency(row.lineCost)}</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={row.notes}
|
||||
oninput={(event) => updateIngredientField(index, 'notes', (event.currentTarget as HTMLInputElement).value)}
|
||||
placeholder="Optional row note"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button class="icon-delete" type="button" onclick={() => removeIngredientRow(index)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="sidebar-stack">
|
||||
<article class="summary-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Live Totals</p>
|
||||
<h3>Worksheet summary</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<article>
|
||||
<span>Total Kg</span>
|
||||
<strong>{totalMixKg.toFixed(2)}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Total Cost</span>
|
||||
<strong>{currency(totalMixCost)}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Cost / Kg</span>
|
||||
<strong>{currency(mixCostPerKg, 4)}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Rows</span>
|
||||
<strong>{draftRows.length}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="summary-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Calculation Factors</p>
|
||||
<h3>What drives cost</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="factor-list">
|
||||
<article>
|
||||
<strong>Market value</strong>
|
||||
<span>Each raw material row uses the active market value from the raw materials module.</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>Waste percentage</strong>
|
||||
<span>Material loss is included in the computed cost per kg used by the worksheet.</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>Kg per unit</strong>
|
||||
<span>Unit conversion already affects each raw material cost per kg before line costs are calculated.</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>Quantity used</strong>
|
||||
<span>Line cost is recalculated instantly from quantity times cost per kg.</span>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="summary-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Draft Checks</p>
|
||||
<h3>Validation</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if draftWarnings.length}
|
||||
<div class="warning-list">
|
||||
{#each draftWarnings as warning}
|
||||
<article>{warning}</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="healthy-card">
|
||||
<strong>Ready to save</strong>
|
||||
<p>This draft has the required metadata and valid ingredient rows.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #7f8e85;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.locked-card,
|
||||
.page-intro,
|
||||
.feedback,
|
||||
.metric-card,
|
||||
.editor-card,
|
||||
.summary-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.16rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.locked-card,
|
||||
.page-intro,
|
||||
.feedback,
|
||||
.metric-row,
|
||||
.editor-grid {
|
||||
margin-bottom: 1.12rem;
|
||||
}
|
||||
|
||||
.locked-card,
|
||||
.page-intro,
|
||||
.editor-card,
|
||||
.summary-card {
|
||||
padding: 1.08rem;
|
||||
}
|
||||
|
||||
.locked-card {
|
||||
display: grid;
|
||||
gap: 0.62rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.locked-card h2,
|
||||
.page-intro h2 {
|
||||
margin: 0.3rem 0 0.4rem;
|
||||
font-size: clamp(1.56rem, 3vw, 2.02rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.locked-card p:last-of-type,
|
||||
.page-intro p:last-child,
|
||||
.metric-card p,
|
||||
.summary-card span,
|
||||
.factor-list span,
|
||||
.healthy-card p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.locked-card a {
|
||||
color: var(--green-deep);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-intro,
|
||||
.section-heading,
|
||||
.intro-actions,
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.68rem;
|
||||
}
|
||||
|
||||
.page-intro {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.82rem;
|
||||
padding: 0.74rem 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border: none;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
|
||||
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.18);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border: 1px solid var(--line-strong);
|
||||
color: #304038;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
padding: 0.86rem 0.94rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feedback.success {
|
||||
color: var(--green-deep);
|
||||
border-color: #d8ecdf;
|
||||
background: #f6fcf8;
|
||||
}
|
||||
|
||||
.feedback.error {
|
||||
color: #a03737;
|
||||
border-color: #f0d9d9;
|
||||
background: #fff8f8;
|
||||
}
|
||||
|
||||
.metric-row,
|
||||
.editor-grid,
|
||||
.meta-grid,
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 1.04rem 1.08rem;
|
||||
}
|
||||
|
||||
.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,
|
||||
.section-heading h4 {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.75fr);
|
||||
}
|
||||
|
||||
.editor-card,
|
||||
.summary-card {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.32rem;
|
||||
color: #53645b;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.82rem 0.88rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0.88rem;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sheet-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sheet-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 0.54rem;
|
||||
}
|
||||
|
||||
.sheet-table th,
|
||||
.sheet-table td {
|
||||
padding: 0.88rem 0.92rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sheet-table th {
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sheet-table tbody td {
|
||||
background: var(--panel-soft);
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sheet-table tbody td:first-child {
|
||||
border-left: 1px solid var(--line);
|
||||
border-radius: 0.92rem 0 0 0.92rem;
|
||||
}
|
||||
|
||||
.sheet-table tbody td:last-child {
|
||||
border-right: 1px solid var(--line);
|
||||
border-radius: 0 0.92rem 0.92rem 0;
|
||||
}
|
||||
|
||||
.sheet-table input,
|
||||
.sheet-table select {
|
||||
min-width: 8rem;
|
||||
padding: 0.7rem 0.78rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.icon-delete {
|
||||
border: 1px solid #eed8d8;
|
||||
border-radius: 0.74rem;
|
||||
padding: 0.64rem 0.78rem;
|
||||
color: #a34a4a;
|
||||
background: #fff7f7;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-stack,
|
||||
.factor-list,
|
||||
.warning-list {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.summary-grid article,
|
||||
.factor-list article {
|
||||
padding: 0.88rem 0.94rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.92rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.summary-grid strong {
|
||||
display: block;
|
||||
margin-top: 0.28rem;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.factor-list strong,
|
||||
.healthy-card strong {
|
||||
display: block;
|
||||
margin-bottom: 0.22rem;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.warning-list article,
|
||||
.healthy-card {
|
||||
padding: 0.9rem 0.94rem;
|
||||
border-radius: 0.92rem;
|
||||
}
|
||||
|
||||
.warning-list article {
|
||||
border: 1px solid #f1e2c2;
|
||||
background: #fffaf2;
|
||||
color: #8d5d21;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.healthy-card {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.editor-grid,
|
||||
.metric-row,
|
||||
.meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page-intro,
|
||||
.section-heading,
|
||||
.intro-actions,
|
||||
.editor-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+262
-7
@@ -1,4 +1,12 @@
|
||||
import type { Mix, Product, ProductCostBreakdown, RawMaterial, Scenario } from '$lib/types';
|
||||
import type {
|
||||
ClientAccessAccount,
|
||||
ClientAccessPowerBiExport,
|
||||
Mix,
|
||||
Product,
|
||||
ProductCostBreakdown,
|
||||
RawMaterial,
|
||||
Scenario
|
||||
} from '$lib/types';
|
||||
|
||||
export const mockRawMaterials: RawMaterial[] = [
|
||||
{
|
||||
@@ -32,8 +40,8 @@ export const mockRawMaterials: RawMaterial[] = [
|
||||
export const mockMixes: Mix[] = [
|
||||
{
|
||||
id: 1,
|
||||
client_name: 'Specialty Feeds',
|
||||
name: 'Pigeon Mix',
|
||||
client_name: 'Hunter Premium Produce',
|
||||
name: 'Hunter Orchard Blend',
|
||||
status: 'active',
|
||||
ingredients: [
|
||||
{
|
||||
@@ -63,10 +71,10 @@ export const mockMixes: Mix[] = [
|
||||
export const mockProducts: Product[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Specialty Pigeon Breeder 20kg',
|
||||
client_name: 'Specialty Feeds',
|
||||
name: 'Hunter Orchard Blend 20kg',
|
||||
client_name: 'Hunter Premium Produce',
|
||||
mix_id: 1,
|
||||
mix_name: 'Pigeon Mix',
|
||||
mix_name: 'Hunter Orchard Blend',
|
||||
sale_type: 'standard',
|
||||
unit_of_measure: '20kg bag',
|
||||
distributor_margin: 0.225,
|
||||
@@ -77,7 +85,7 @@ export const mockProducts: Product[] = [
|
||||
export const mockCosts: ProductCostBreakdown[] = [
|
||||
{
|
||||
product_id: 1,
|
||||
product_name: 'Specialty Pigeon Breeder 20kg',
|
||||
product_name: 'Hunter Orchard Blend 20kg',
|
||||
finished_product_delivered: 14.208,
|
||||
distributor_price: 18.3329,
|
||||
wholesale_price: 17.3268,
|
||||
@@ -94,3 +102,250 @@ export const mockScenarios: Scenario[] = [
|
||||
overrides: {}
|
||||
}
|
||||
];
|
||||
|
||||
export const mockClientAccess: ClientAccessAccount[] = [
|
||||
{
|
||||
id: 1,
|
||||
tenant_id: 'hunter-premium-produce',
|
||||
name: 'Hunter Premium Produce',
|
||||
client_code: 'HPP',
|
||||
status: 'active',
|
||||
powerbi_workspace: 'hunter-premium-produce-prod',
|
||||
notes: 'Primary production client for the Lean 101 admin and access workflows',
|
||||
created_at: '2026-04-20T09:00:00',
|
||||
active_user_count: 1,
|
||||
new_user_count: 1,
|
||||
enabled_feature_count: 6,
|
||||
total_feature_count: 6,
|
||||
users: [
|
||||
{
|
||||
id: 1,
|
||||
client_account_id: 1,
|
||||
full_name: 'Amelia Hart',
|
||||
email: 'operator@example.com',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
is_new_user: false,
|
||||
last_login_at: '2026-04-24T11:30:00',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
client_account_id: 1,
|
||||
full_name: 'Ethan Cole',
|
||||
email: 'ethan.cole@hunterpremiumproduce.example',
|
||||
role: 'operator',
|
||||
status: 'invited',
|
||||
is_new_user: true,
|
||||
last_login_at: null,
|
||||
created_at: '2026-04-24T15:00:00'
|
||||
}
|
||||
],
|
||||
features: [
|
||||
{
|
||||
id: 1,
|
||||
client_account_id: 1,
|
||||
feature_key: 'dashboard',
|
||||
feature_name: 'Dashboard',
|
||||
feature_group: 'workspace',
|
||||
description: 'Top-level operational dashboard',
|
||||
enabled: true,
|
||||
updated_at: '2026-04-24T15:00:00',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
client_account_id: 1,
|
||||
feature_key: 'raw_materials',
|
||||
feature_name: 'Raw Materials',
|
||||
feature_group: 'costing',
|
||||
description: 'Maintain live material costs and versions',
|
||||
enabled: true,
|
||||
updated_at: '2026-04-24T15:00:00',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
client_account_id: 1,
|
||||
feature_key: 'mix_master',
|
||||
feature_name: 'Mix Master',
|
||||
feature_group: 'costing',
|
||||
description: 'Create and maintain mix worksheets',
|
||||
enabled: true,
|
||||
updated_at: '2026-04-24T15:00:00',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
client_account_id: 1,
|
||||
feature_key: 'products',
|
||||
feature_name: 'Products',
|
||||
feature_group: 'pricing',
|
||||
description: 'Review finished product pricing',
|
||||
enabled: true,
|
||||
updated_at: '2026-04-24T15:00:00',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
client_account_id: 1,
|
||||
feature_key: 'scenarios',
|
||||
feature_name: 'Scenarios',
|
||||
feature_group: 'planning',
|
||||
description: 'Run scenario overrides and comparisons',
|
||||
enabled: true,
|
||||
updated_at: '2026-04-24T15:00:00',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
client_account_id: 1,
|
||||
feature_key: 'powerbi_export',
|
||||
feature_name: 'Power BI Export',
|
||||
feature_group: 'reporting',
|
||||
description: 'Expose client access data to BI consumers',
|
||||
enabled: true,
|
||||
updated_at: '2026-04-24T15:00:00',
|
||||
created_at: '2026-04-20T09:00:00'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tenant_id: 'loft-grains',
|
||||
name: 'Loft Grains',
|
||||
client_code: 'LOFT',
|
||||
status: 'onboarding',
|
||||
powerbi_workspace: 'farm-ops-sandbox',
|
||||
notes: 'Onboarding workspace used to test staged user enablement',
|
||||
created_at: '2026-04-21T10:00:00',
|
||||
active_user_count: 1,
|
||||
new_user_count: 0,
|
||||
enabled_feature_count: 3,
|
||||
total_feature_count: 6,
|
||||
users: [
|
||||
{
|
||||
id: 3,
|
||||
client_account_id: 2,
|
||||
full_name: 'Ruby Singh',
|
||||
email: 'ruby.singh@loftgrains.example',
|
||||
role: 'viewer',
|
||||
status: 'active',
|
||||
is_new_user: false,
|
||||
last_login_at: '2026-04-22T09:10:00',
|
||||
created_at: '2026-04-21T10:00:00'
|
||||
}
|
||||
],
|
||||
features: [
|
||||
{
|
||||
id: 7,
|
||||
client_account_id: 2,
|
||||
feature_key: 'dashboard',
|
||||
feature_name: 'Dashboard',
|
||||
feature_group: 'workspace',
|
||||
description: 'Top-level operational dashboard',
|
||||
enabled: true,
|
||||
updated_at: '2026-04-22T09:10:00',
|
||||
created_at: '2026-04-21T10:00:00'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
client_account_id: 2,
|
||||
feature_key: 'raw_materials',
|
||||
feature_name: 'Raw Materials',
|
||||
feature_group: 'costing',
|
||||
description: 'Maintain live material costs and versions',
|
||||
enabled: false,
|
||||
updated_at: '2026-04-22T09:10:00',
|
||||
created_at: '2026-04-21T10:00:00'
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
client_account_id: 2,
|
||||
feature_key: 'mix_master',
|
||||
feature_name: 'Mix Master',
|
||||
feature_group: 'costing',
|
||||
description: 'Create and maintain mix worksheets',
|
||||
enabled: false,
|
||||
updated_at: '2026-04-22T09:10:00',
|
||||
created_at: '2026-04-21T10:00:00'
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
client_account_id: 2,
|
||||
feature_key: 'products',
|
||||
feature_name: 'Products',
|
||||
feature_group: 'pricing',
|
||||
description: 'Review finished product pricing',
|
||||
enabled: true,
|
||||
updated_at: '2026-04-22T09:10:00',
|
||||
created_at: '2026-04-21T10:00:00'
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
client_account_id: 2,
|
||||
feature_key: 'scenarios',
|
||||
feature_name: 'Scenarios',
|
||||
feature_group: 'planning',
|
||||
description: 'Run scenario overrides and comparisons',
|
||||
enabled: false,
|
||||
updated_at: '2026-04-22T09:10:00',
|
||||
created_at: '2026-04-21T10:00:00'
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
client_account_id: 2,
|
||||
feature_key: 'powerbi_export',
|
||||
feature_name: 'Power BI Export',
|
||||
feature_group: 'reporting',
|
||||
description: 'Expose client access data to BI consumers',
|
||||
enabled: true,
|
||||
updated_at: '2026-04-22T09:10:00',
|
||||
created_at: '2026-04-21T10:00:00'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const mockClientAccessExport: ClientAccessPowerBiExport = {
|
||||
generated_at: '2026-04-25T09:00:00',
|
||||
client_rows: mockClientAccess.map((client) => ({
|
||||
client_id: client.id,
|
||||
tenant_id: client.tenant_id,
|
||||
client_name: client.name,
|
||||
client_code: client.client_code,
|
||||
client_status: client.status,
|
||||
powerbi_workspace: client.powerbi_workspace,
|
||||
active_user_count: client.active_user_count,
|
||||
new_user_count: client.new_user_count,
|
||||
enabled_feature_count: client.enabled_feature_count,
|
||||
total_feature_count: client.total_feature_count
|
||||
})),
|
||||
user_rows: mockClientAccess.flatMap((client) =>
|
||||
client.users.map((user) => ({
|
||||
client_id: client.id,
|
||||
client_name: client.name,
|
||||
user_id: user.id,
|
||||
full_name: user.full_name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
is_new_user: user.is_new_user,
|
||||
last_login_at: user.last_login_at,
|
||||
created_at: user.created_at
|
||||
}))
|
||||
),
|
||||
feature_rows: mockClientAccess.flatMap((client) =>
|
||||
client.features.map((feature) => ({
|
||||
client_id: client.id,
|
||||
client_name: client.name,
|
||||
feature_id: feature.id,
|
||||
feature_key: feature.feature_key,
|
||||
feature_name: feature.feature_name,
|
||||
feature_group: feature.feature_group,
|
||||
enabled: feature.enabled,
|
||||
updated_at: feature.updated_at
|
||||
}))
|
||||
),
|
||||
clients: mockClientAccess
|
||||
};
|
||||
|
||||
+34
-14
@@ -1,58 +1,78 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type OperatorSession = {
|
||||
export type AppSession = {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
token: string;
|
||||
tenant_id?: string | null;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'data-entry-app-operator-session';
|
||||
const CLIENT_STORAGE_KEY = 'data-entry-app-client-session';
|
||||
const ADMIN_STORAGE_KEY = 'data-entry-app-admin-session';
|
||||
|
||||
function readSession(): OperatorSession | null {
|
||||
function readStoredSession(storageKey: string): AppSession | null {
|
||||
if (!browser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = localStorage.getItem(STORAGE_KEY);
|
||||
const value = localStorage.getItem(storageKey);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as OperatorSession;
|
||||
return JSON.parse(value) as AppSession;
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(storageKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createOperatorSessionStore() {
|
||||
const store = writable<OperatorSession | null>(readSession());
|
||||
function createSessionStore(storageKey: string) {
|
||||
const store = writable<AppSession | null>(readStoredSession(storageKey));
|
||||
|
||||
if (browser) {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === STORAGE_KEY) {
|
||||
store.set(readSession());
|
||||
if (event.key === storageKey) {
|
||||
store.set(readStoredSession(storageKey));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
set(session: OperatorSession) {
|
||||
set(session: AppSession) {
|
||||
if (browser) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
localStorage.setItem(storageKey, JSON.stringify(session));
|
||||
}
|
||||
store.set(session);
|
||||
},
|
||||
clear() {
|
||||
if (browser) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
store.set(null);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const operatorSession = createOperatorSessionStore();
|
||||
export function getStoredClientSession() {
|
||||
return readStoredSession(CLIENT_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getStoredAdminSession() {
|
||||
return readStoredSession(ADMIN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function hasStoredClientSession() {
|
||||
return getStoredClientSession() !== null;
|
||||
}
|
||||
|
||||
export function hasStoredAdminSession() {
|
||||
return getStoredAdminSession() !== null;
|
||||
}
|
||||
|
||||
export const clientSession = createSessionStore(CLIENT_STORAGE_KEY);
|
||||
export const adminSession = createSessionStore(ADMIN_STORAGE_KEY);
|
||||
|
||||
@@ -48,6 +48,34 @@ export type Mix = {
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type MixIngredientInput = {
|
||||
raw_material_id: number;
|
||||
quantity_kg: number;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type MixCreateInput = {
|
||||
client_name: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
version?: number;
|
||||
notes?: string | null;
|
||||
ingredients: MixIngredientInput[];
|
||||
};
|
||||
|
||||
export type MixUpdateInput = {
|
||||
client_name?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
version?: number;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type MixIngredientUpdateInput = {
|
||||
quantity_kg?: number;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: number;
|
||||
tenant_id?: string;
|
||||
@@ -90,10 +118,63 @@ export type Scenario = {
|
||||
overrides: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ClientAccessUser = {
|
||||
id: number;
|
||||
client_account_id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
is_new_user: boolean;
|
||||
last_login_at?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ClientAccessFeature = {
|
||||
id: number;
|
||||
client_account_id: number;
|
||||
feature_key: string;
|
||||
feature_name: string;
|
||||
feature_group: string;
|
||||
description?: string | null;
|
||||
enabled: boolean;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ClientAccessAccount = {
|
||||
id: number;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
client_code: string;
|
||||
status: string;
|
||||
powerbi_workspace?: string | null;
|
||||
notes?: string | null;
|
||||
created_at: string;
|
||||
users: ClientAccessUser[];
|
||||
features: ClientAccessFeature[];
|
||||
active_user_count: number;
|
||||
new_user_count: number;
|
||||
enabled_feature_count: number;
|
||||
total_feature_count: number;
|
||||
};
|
||||
|
||||
export type ClientAccessExportRow = Record<string, unknown>;
|
||||
|
||||
export type ClientAccessPowerBiExport = {
|
||||
generated_at: string;
|
||||
client_rows: ClientAccessExportRow[];
|
||||
user_rows: ClientAccessExportRow[];
|
||||
feature_rows: ClientAccessExportRow[];
|
||||
clients: ClientAccessAccount[];
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
token: string;
|
||||
tenant_id?: string | null;
|
||||
};
|
||||
|
||||
export type RawMaterialCreateInput = {
|
||||
@@ -119,3 +200,20 @@ export type RawMaterialPriceCreateInput = {
|
||||
status?: string;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type ClientUserCreateInput = {
|
||||
client_account_id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
is_new_user?: boolean;
|
||||
};
|
||||
|
||||
export type ClientUserUpdateInput = {
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
is_new_user?: boolean;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
+1157
-402
File diff suppressed because it is too large
Load Diff
@@ -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: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user