v0.1.11 - Editor

This commit is contained in:
2026-06-03 00:17:12 +12:00
parent f5a588d631
commit cf968e802b
23 changed files with 2165 additions and 655 deletions
+9 -1
View File
@@ -88,7 +88,15 @@ If your server already has a host-level nginx handling domains and TLS, use `dep
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
```
Useful flags: `-Branch <name>` to deploy a feature branch, `-SkipBuild` for env-only changes, `-Seed` to re-run reference data seeding, `-Logs` to tail logs after the deploy, `-SshKey` to point at a specific private key.
To deploy with password-based SSH auth instead of a key:
```powershell
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 -Password 'your-password'
```
Password auth requires `sshpass` on your local `PATH`.
Useful flags: `-Branch <name>` to deploy a feature branch, `-SkipBuild` for env-only changes, `-Seed` to re-run reference data seeding, `-Logs` to tail logs after the deploy, `-SshKey` to point at a specific private key, `-Password` for password-based SSH auth.
If a release adds or changes database-backed workbook formula structures, deploy with `-Seed` so the server refreshes seeded reference/formula data after the backend starts. For the product-formula change, this is required so Postgres receives the new `product_ingredients` rows sourced from `input_data/1.xlsx`.
+3 -3
View File
@@ -28,7 +28,7 @@ router = APIRouter(prefix="/api/client-access", tags=["client-access"])
def _authorized_client_scope(db: Session, session: AuthSession) -> list[ClientAccount]:
clients = list_client_accounts(db)
if session.role == "admin":
if session.role in {"admin", "internal"}:
return clients
return [client for client in clients if client.id == session.client_account_id]
@@ -61,12 +61,12 @@ def _read_client_account(db: Session, client_id: int, session: AuthSession) -> d
def _actor_metadata(session: AuthSession) -> dict[str, str]:
if session.role == "admin":
if session.role in {"admin", "internal"}:
return {
"actor_type": "lean_admin",
"actor_name": session.name,
"actor_email": session.email,
"actor_role": "admin",
"actor_role": session.client_role or "admin",
}
return {
"actor_type": "client_superadmin",
+6
View File
@@ -190,6 +190,12 @@ def require_client_access_manager_session(
) -> AuthSession:
if session.role == "admin":
return session
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get("client_access"), "manage"):
log_security_event("authz.denied", role=session.role, module="client_access", access_level="manage")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires Lean access")
return session
if session.role != "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires admin or superadmin access")
+137
View File
@@ -0,0 +1,137 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, joinedload
from app.api.deps import AuthSession, get_auth_session
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
from app.schemas.editor import EditorMixUpdate, EditorProductRow, EditorProductUpdate
from app.services.client_access_service import has_access_level
router = APIRouter(prefix="/api/editor", tags=["editor"])
def _serialize_row(product: Product) -> dict:
return {
"id": product.id,
"tenant_id": product.tenant_id,
"client_name": product.client_name,
"item_id": product.item_id,
"name": product.name,
"mix_id": product.mix_id,
"mix_client_name": product.mix.client_name if product.mix else "",
"mix_name": product.mix.name if product.mix else "",
"sale_type": product.sale_type,
"unit_of_measure": product.unit_of_measure,
"visible": product.visible,
"product_notes": product.notes,
"mix_notes": product.mix.notes if product.mix else None,
}
def _require_editor_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
) -> AuthSession:
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get("client_access"), "manage"):
raise HTTPException(status_code=403, detail="Lean access is required")
if not has_access_level(permissions.get("products"), "edit"):
raise HTTPException(status_code=403, detail="products edit access is required")
if not has_access_level(permissions.get("mix_master"), "edit"):
raise HTTPException(status_code=403, detail="mix_master edit access is required")
if not session.tenant_id:
raise HTTPException(status_code=403, detail="Internal user context is missing")
return session
raise HTTPException(status_code=403, detail="Lean access is required")
@router.get("/products", response_model=list[EditorProductRow])
def list_editor_products(
q: str | None = Query(default=None, max_length=255),
client_name: str | None = Query(default=None, max_length=255),
limit: int = Query(default=500, ge=1, le=1000),
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
statement = (
select(Product)
.where(Product.tenant_id == session.tenant_id)
.options(joinedload(Product.mix))
.join(Product.mix)
.order_by(Product.client_name, Product.name, Product.id)
.limit(limit)
)
if client_name:
statement = statement.where(Product.client_name == client_name)
if q:
term = f"%{q.strip()}%"
statement = statement.where(
or_(
Product.client_name.ilike(term),
Product.name.ilike(term),
Product.item_id.ilike(term),
Product.unit_of_measure.ilike(term),
Mix.name.ilike(term),
)
)
return [_serialize_row(product) for product in db.scalars(statement).all()]
@router.patch("/products/{product_id}", response_model=EditorProductRow)
def update_editor_product(
product_id: int,
payload: EditorProductUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == session.tenant_id)
.options(joinedload(Product.mix))
)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
if payload.mix_id is not None:
mix = db.scalar(select(Mix).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value)
db.commit()
db.refresh(product)
return _serialize_row(product)
@router.patch("/mixes/{mix_id}", response_model=list[EditorProductRow])
def update_editor_mix(
mix_id: int,
payload: EditorMixUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(mix, field, value)
db.commit()
products = db.scalars(
select(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
.options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name, Product.id)
).all()
return [_serialize_row(product) for product in products]
+6 -4
View File
@@ -53,10 +53,12 @@ _PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
"edit_mixes": ("mix_master", "edit"),
"view_throughput": ("operations_throughput", "view"),
"edit_throughput": ("operations_throughput", "edit"),
# Admin-only permissions (view_users, manage_users, manage_permissions,
# view_settings, edit_settings) are intentionally excluded — they don't
# correspond to any of the legacy module keys and remain accessible only
# via the explicit `require_permission(...)` dependency.
"view_scenarios": ("scenarios", "view"),
"edit_scenarios": ("scenarios", "edit"),
"manage_client_access": ("client_access", "manage"),
# User/role/settings permissions are intentionally excluded — they don't
# correspond to legacy module keys and remain accessible only via the
# explicit `require_permission(...)` dependency.
}
_ACCESS_LEVEL_RANK = {"none": 0, "view": 1, "edit": 2, "manage": 3}
+2
View File
@@ -22,6 +22,7 @@ from app.api.access import router as access_router
from app.api.auth import router as auth_router
from app.api.client_access import router as client_access_router
from app.api.dashboard import router as dashboard_router
from app.api.editor import router as editor_router
from app.api.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_router
@@ -194,6 +195,7 @@ app.include_router(auth_router)
app.include_router(access_router)
app.include_router(client_access_router)
app.include_router(dashboard_router)
app.include_router(editor_router)
app.include_router(raw_materials_router)
app.include_router(mixes_router)
app.include_router(mix_calculator_router)
+38
View File
@@ -0,0 +1,38 @@
from pydantic import BaseModel, ConfigDict, Field
class EditorProductRow(BaseModel):
id: int
tenant_id: str
client_name: str
item_id: str | None
name: str
mix_id: int
mix_client_name: str
mix_name: str
sale_type: str
unit_of_measure: str
visible: bool
product_notes: str | None
mix_notes: str | None
class EditorProductUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
client_name: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255)
item_id: str | None = Field(default=None, max_length=128)
mix_id: int | None = None
sale_type: str | None = Field(default=None, min_length=1, max_length=64)
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
visible: bool | None = None
notes: str | None = Field(default=None, max_length=2000)
class EditorMixUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
client_name: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255)
notes: str | None = Field(default=None, max_length=2000)
+32
View File
@@ -31,6 +31,9 @@ PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
("edit_mixes", "Create and edit mix master recipes"),
("view_throughput", "View operations throughput"),
("edit_throughput", "Create and edit operations throughput entries"),
("view_scenarios", "View scenario planning"),
("edit_scenarios", "Create, run, approve, and reject scenarios"),
("manage_client_access", "Manage client accounts, users, feature access, and exports"),
("view_users", "View internal users and roles"),
("manage_users", "Create, deactivate, and assign user roles"),
("manage_permissions", "Modify roles and role-permission assignments"),
@@ -50,10 +53,14 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"view_raw_materials",
"edit_raw_materials",
"view_products",
"edit_products",
"view_mixes",
"edit_mixes",
"view_throughput",
"edit_throughput",
"view_scenarios",
"edit_scenarios",
"manage_client_access",
"view_users",
"manage_users",
"manage_permissions",
@@ -88,6 +95,31 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_throughput",
],
},
"lean": {
"description": "Lean owner access with unrestricted view/edit access across every workspace module.",
"permissions": [
"view_dashboard",
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_raw_materials",
"edit_raw_materials",
"view_products",
"edit_products",
"view_mixes",
"edit_mixes",
"view_throughput",
"edit_throughput",
"view_scenarios",
"edit_scenarios",
"manage_client_access",
"view_users",
"manage_users",
"manage_permissions",
"view_settings",
"edit_settings",
],
},
}
+36 -3
View File
@@ -11,6 +11,7 @@ from app.api.access import router as access_router
from app.core.access import (
INTERNAL_USER_SUBJECT,
get_user_permissions,
permissions_to_module_map,
require_all_permissions,
require_any_permission,
require_permission,
@@ -72,9 +73,17 @@ def test_admin_role_permissions_match_spec():
assert granted == set(ROLE_DEFINITIONS["Admin"]["permissions"])
assert "manage_users" in granted
assert "manage_permissions" in granted
# Admin spec deliberately excludes edit_products / edit_mixes.
assert "edit_products" not in granted
assert "edit_mixes" not in granted
assert "edit_products" in granted
assert "edit_mixes" in granted
assert "view_scenarios" in granted
assert "edit_scenarios" in granted
assert "manage_client_access" in granted
modules = permissions_to_module_map(granted)
assert modules["products"] == "edit"
assert modules["mix_master"] == "edit"
assert modules["scenarios"] == "edit"
assert modules["client_access"] == "manage"
def test_operations_role_is_mix_calculator_and_throughput_only():
@@ -108,6 +117,30 @@ def test_full_access_role_can_edit_operational_data_but_not_users():
assert "manage_permissions" not in granted
def test_lean_role_has_unrestricted_workspace_permissions():
db = _build_session()
seed_access(db)
lean_role = db.query(Role).filter_by(name="lean").one()
user = User(email="lean@example.com", name="Lean User", role_id=lean_role.id, is_active=True)
db.add(user)
db.flush()
granted = get_user_permissions(user)
assert granted == set(ROLE_DEFINITIONS["lean"]["permissions"])
assert {key for key, _ in PERMISSION_DEFINITIONS} == granted
modules = permissions_to_module_map(granted)
assert modules["dashboard"] == "view"
assert modules["raw_materials"] == "edit"
assert modules["mix_master"] == "edit"
assert modules["mix_calculator"] == "edit"
assert modules["products"] == "edit"
assert modules["operations_throughput"] == "edit"
assert modules["scenarios"] == "edit"
assert modules["client_access"] == "manage"
def test_inactive_user_has_no_permissions():
db = _build_session()
seed_access(db)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hunter-app",
"version": "0.1.8",
"version": "0.1.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hunter-app",
"version": "0.1.8",
"version": "0.1.11",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hunter-app",
"version": "0.1.9",
"version": "0.1.11",
"private": true,
"type": "module",
"scripts": {
+22
View File
@@ -19,6 +19,9 @@ import type {
ClientUserModulePermission,
ClientUserUpdateInput,
LoginResponse,
EditorMixUpdateInput,
EditorProductRow,
EditorProductUpdateInput,
MixCalculatorCreateInput,
MixCalculatorOptions,
MixCalculatorPreview,
@@ -318,6 +321,25 @@ export const api = {
body: JSON.stringify(payload)
}, 'client'),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
editorProducts: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.q) search.set('q', params.q);
if (params?.client_name) search.set('client_name', params.client_name);
if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString();
const path = qs ? `/api/editor/products?${qs}` : '/api/editor/products';
return cachedFetchJson<EditorProductRow[]>(path, [], 'client', fetcher);
},
updateEditorProduct: (productId: number, payload: EditorProductUpdateInput) =>
request<EditorProductRow>(`/api/editor/products/${productId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
request<EditorProductRow[]>(`/api/editor/mixes/${mixId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
+391 -45
View File
@@ -14,12 +14,10 @@
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenClientAccess as sessionCanOpenClientAccess,
canOpenDashboard as sessionCanOpenDashboard,
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster,
canOpenProducts as sessionCanOpenProducts,
canOpenRawMaterials as sessionCanOpenRawMaterials,
canOpenReporting as sessionCanOpenReporting,
canOpenScenarios as sessionCanOpenScenarios,
canOpenSettings as sessionCanOpenSettings,
canOpenThroughput as sessionCanOpenThroughput,
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
@@ -32,6 +30,7 @@
baseSearchItems,
clientBreadcrumbs,
dashboardItem,
editorItem,
footerLinks,
matchesRoute,
mixCalculatorItem,
@@ -47,11 +46,11 @@
import packageInfo from '../../../package.json';
import {
Calculator,
DollarSign,
Search,
LogOut,
Plus,
Menu
Menu,
Settings
} from 'lucide-svelte';
let { children } = $props();
@@ -71,13 +70,11 @@
const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear();
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
const canOpenRawMaterials = $derived(sessionCanOpenRawMaterials($clientSession));
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
const canOpenProducts = $derived(sessionCanOpenProducts($clientSession));
const canOpenScenarios = $derived(sessionCanOpenScenarios($clientSession));
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
const canOpenClientAccess = $derived(sessionCanOpenClientAccess($clientSession));
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
@@ -94,10 +91,7 @@
!$clientSession
? workingDocumentItems
: workingDocumentItems.filter((item) => {
if (item.href === '/raw-materials') return canOpenRawMaterials;
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/products') return canOpenProducts;
if (item.href === '/scenarios') return canOpenScenarios;
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
})
);
@@ -105,6 +99,7 @@
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([
@@ -126,14 +121,12 @@
const visibleBaseSearchItems = $derived(
baseSearchItems.filter((item) => {
if (item.href === '/') return canOpenDashboard;
if (item.href === '/raw-materials') return canOpenRawMaterials;
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/mixes/new') return canCreateMixWorksheet;
if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/products') return canOpenProducts;
if (item.href === '/editor') return canOpenEditor;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings;
if (item.href === '/scenarios') return canOpenScenarios;
return true;
})
);
@@ -266,24 +259,17 @@
seededSearchKey = sessionKey;
Promise.all([
sessionCanOpenProducts(session) ? api.products() : Promise.resolve([]),
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
? api.mixCalculatorSessions()
: Promise.resolve([])
])
.then(([products, mixes, sessions]) => {
.then(([mixes, sessions]) => {
if (seededSearchKey !== sessionKey) {
return;
}
seededSearchItems = [
...products.map((product) => ({
href: '/products',
label: product.name,
description: `Product · ${product.client_name} · ${product.mix_name}`,
keywords: `product ${product.name} ${product.client_name} ${product.mix_name} ${product.unit_of_measure}`
})),
...mixes.map((mix) => ({
href: `/mixes/${mix.id}`,
label: mix.name,
@@ -394,6 +380,7 @@
primaryItems={[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleThroughputItem ? [visibleThroughputItem] : []),
...(visibleReportingItem ? [visibleReportingItem] : [])
]}
@@ -458,16 +445,13 @@
{#if canCreateMixSession}
<a href="/mix-calculator">Create mix session</a>
{/if}
{#if canOpenProducts}
<a href="/products">Review delivered pricing</a>
{/if}
{#if canUseWorkspaceSearch}
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
{/if}
</div>
{/if}
{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canOpenProducts || canUseWorkspaceSearch}
{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canUseWorkspaceSearch}
<button
aria-expanded={quickMenuOpen}
aria-label="Open quick access menu"
@@ -553,6 +537,15 @@
</a>
{/if}
{#if visibleEditorItem}
{@const Icon = visibleEditorItem.icon}
<a class:active={matchesRoute(visibleEditorItem.href, page.url.pathname)} href={visibleEditorItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleEditorItem.label}</span>
{#if visibleEditorItem.badge}<span class="drawer-badge">{visibleEditorItem.badge}</span>{/if}
</a>
{/if}
{#if visibleReportingItem}
{@const Icon = visibleReportingItem.icon}
<a class:active={matchesRoute(visibleReportingItem.href, page.url.pathname)} href={visibleReportingItem.href} onclick={() => (navOpen = false)}>
@@ -593,12 +586,6 @@
<span>Change settings</span>
</button>
{/if}
{#if canOpenProducts}
<a href="/products" onclick={() => (navOpen = false)}>
<span class="nav-icon"><DollarSign size={18} strokeWidth={1.75} /></span>
<span>Review delivered pricing</span>
</a>
{/if}
{#if canUseWorkspaceSearch}
<button type="button" onclick={() => openPalette('')}>
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
@@ -645,7 +632,7 @@
>
<div class="palette-input-row">
<span class="search-icon"></span>
<input bind:this={paletteInput} bind:value={paletteQuery} placeholder="Search products, mixes, sessions, and pages..." />
<input bind:this={paletteInput} bind:value={paletteQuery} placeholder="Search mixes, sessions, and pages..." />
<kbd>Esc</kbd>
</div>
@@ -663,7 +650,7 @@
{:else}
<div class="palette-empty">
<strong>No results</strong>
<span>Try searching for mixes, products, scenarios, or pricing.</span>
<span>Try searching for mixes, sessions, or pages.</span>
</div>
{/if}
</div>
@@ -674,27 +661,31 @@
<style>
:global(:root) {
/* ── Brand ──────────────────────────────────────────────── */
--color-brand: #15803d;
--color-brand-tint: #f0fdf4;
--color-brand: oklch(0.54 0.15 149);
--color-brand-hover: oklch(0.47 0.14 149);
--color-brand-tint: oklch(0.98 0.02 149);
/* ── Surfaces ───────────────────────────────────────────── */
--color-bg-app: #f6f8fa;
--color-bg-surface: #ffffff;
--color-bg-app: oklch(0.975 0.006 150);
--color-bg-surface: oklch(0.997 0.004 150);
--color-bg-elevated: oklch(0.99 0.005 150);
/* ── Borders ────────────────────────────────────────────── */
--color-border: #e1e4e8;
--color-divider: #eaecef;
--color-border: oklch(0.905 0.012 150);
--color-divider: oklch(0.935 0.009 150);
/* ── Text ───────────────────────────────────────────────── */
--color-text-primary: #24292f;
--color-text-secondary: #57606a;
--color-text-muted: #8b949e;
--color-text-primary: oklch(0.26 0.015 150);
--color-text-secondary: oklch(0.44 0.018 150);
--color-text-muted: oklch(0.62 0.018 150);
/* ── Semantic ───────────────────────────────────────────── */
--color-success: #1a7f37;
--color-warning: #bf8700;
--color-error: #cf222e;
--color-info: #0969da;
--color-warning-tint: oklch(0.975 0.035 78);
--color-info-tint: oklch(0.97 0.025 230);
/* ── Legacy aliases (keep old token names working) ───────── */
--bg: var(--color-bg-app);
@@ -705,10 +696,15 @@
--text: var(--color-text-primary);
--muted: var(--color-text-muted);
--green: var(--color-brand);
--green-deep: #1a1f1c;
--green-deep: oklch(0.25 0.018 150);
--green-soft: var(--color-brand-tint);
--blue-soft: #e8f4ff;
--blue-soft: var(--color-info-tint);
--shadow: none; /* flat design — use borders, not shadows */
--radius-panel: 1.2rem;
--radius-control: 0.82rem;
--radius-row: 0.95rem;
--space-page: 1.25rem;
--space-card: 1.15rem;
}
:global(html, body) {
@@ -741,6 +737,356 @@
text-decoration: none;
}
:global(:focus-visible) {
outline: 3px solid color-mix(in srgb, var(--color-brand) 38%, transparent);
outline-offset: 2px;
}
:global(.ui-stack) {
display: grid;
gap: var(--space-page);
}
:global(.ui-panel),
:global(.ui-metric-card) {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
box-shadow: var(--shadow);
}
:global(.ui-panel) {
padding: var(--space-card);
}
:global(.ui-panel-soft) {
background: var(--panel-soft);
border: 1px solid var(--line);
border-radius: var(--radius-row);
}
:global(.ui-section-heading) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
margin-bottom: 1rem;
}
:global(.ui-section-heading h3),
:global(.ui-section-heading h4) {
margin: 0.18rem 0 0;
font-size: 1.06rem;
font-weight: 700;
letter-spacing: 0;
}
:global(.ui-eyebrow) {
margin: 0;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
:global(.ui-muted) {
color: var(--muted);
}
:global(.ui-metric-row) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
:global(.ui-metric-card) {
padding: 1.05rem 1.1rem;
}
:global(.ui-metric-card span) {
display: block;
color: var(--muted);
font-size: 0.84rem;
}
:global(.ui-metric-card strong) {
display: block;
margin: 0.5rem 0 0.28rem;
font-size: 1.75rem;
font-weight: 700;
}
:global(.ui-metric-card p) {
margin: 0;
color: var(--muted);
}
:global(.ui-button) {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.6rem;
padding: 0.72rem 0.9rem;
border-radius: var(--radius-control);
font-weight: 600;
cursor: pointer;
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1), border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), color 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
:global(.ui-button.primary) {
border: 1px solid var(--color-brand);
color: oklch(0.99 0.004 150);
background: var(--color-brand);
}
:global(.ui-button.primary:hover:not(:disabled)) {
background: var(--color-brand-hover);
border-color: var(--color-brand-hover);
}
:global(.ui-button.secondary) {
border: 1px solid var(--line-strong);
color: var(--text);
background: var(--panel);
}
:global(.ui-button.secondary:hover:not(:disabled)) {
background: var(--panel-soft);
}
:global(.ui-button:disabled) {
opacity: 0.55;
cursor: not-allowed;
}
:global(.ui-pill) {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
border-radius: 999px;
padding: 0.4rem 0.74rem;
font-size: 0.82rem;
font-weight: 600;
text-transform: capitalize;
white-space: nowrap;
}
:global(.ui-pill.positive) {
color: var(--green-deep);
background: var(--green-soft);
}
:global(.ui-pill.warning) {
color: oklch(0.45 0.11 69);
background: var(--color-warning-tint);
}
:global(.ui-pill.neutral) {
color: var(--color-text-secondary);
background: color-mix(in srgb, var(--panel-soft) 74%, var(--panel));
}
:global(.ui-table-wrap) {
overflow-x: auto;
}
:global(.ui-table) {
width: 100%;
min-width: 48rem;
border-collapse: separate;
border-spacing: 0 0.65rem;
}
:global(.ui-table th),
:global(.ui-table td) {
padding: 0.9rem 0.95rem;
text-align: left;
white-space: nowrap;
}
:global(.ui-table th) {
color: var(--muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
:global(.ui-table tbody td) {
background: var(--panel-soft);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
:global(.ui-table tbody td:first-child) {
border-left: 1px solid var(--line);
border-radius: var(--radius-row) 0 0 var(--radius-row);
}
:global(.ui-table tbody td:last-child) {
border-right: 1px solid var(--line);
border-radius: 0 var(--radius-row) var(--radius-row) 0;
}
:global(.ui-table-identity) {
display: flex;
align-items: center;
gap: 0.74rem;
min-width: 0;
}
:global(.ui-row-mark) {
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.76rem;
color: oklch(0.99 0.004 150);
background: var(--green-deep);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
}
:global(.ui-table-identity strong),
:global(.ui-number-block strong) {
display: block;
font-size: 0.94rem;
}
:global(.ui-table-identity span),
:global(.ui-number-block span) {
display: block;
margin-top: 0.16rem;
color: var(--muted);
font-size: 0.8rem;
}
:global(.ui-number-block) {
display: grid;
gap: 0.08rem;
}
:global(.ui-form-grid) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
:global(.ui-form-grid.compact) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
:global(.ui-field) {
display: grid;
gap: 0.36rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
font-weight: 600;
}
:global(.ui-field input),
:global(.ui-field textarea),
:global(.ui-field select) {
width: 100%;
padding: 0.82rem 0.9rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-control);
background: var(--panel-soft);
color: var(--text);
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1), border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
:global(.ui-field input:focus),
:global(.ui-field textarea:focus),
:global(.ui-field select:focus) {
outline: none;
border-color: var(--color-brand);
background: var(--panel);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
@media (max-width: 980px) {
:global(.ui-metric-row) {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
:global(.ui-section-heading) {
flex-direction: column;
align-items: flex-start;
}
:global(.ui-table) {
min-width: 0;
border-spacing: 0;
}
:global(.ui-table),
:global(.ui-table thead),
:global(.ui-table tbody),
:global(.ui-table tr),
:global(.ui-table td) {
display: block;
width: 100%;
}
:global(.ui-table thead) {
display: none;
}
:global(.ui-table tbody) {
display: grid;
gap: 0.85rem;
}
:global(.ui-table tbody tr) {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: var(--radius-row);
background: var(--panel-soft);
}
:global(.ui-table tbody td) {
padding: 0.76rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
:global(.ui-table tbody td:first-child),
:global(.ui-table tbody td:last-child) {
border: none;
border-radius: 0;
}
:global(.ui-table tbody td + td) {
border-top: 1px solid var(--line);
}
:global(.ui-table tbody td::before) {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
:global(.ui-form-grid),
:global(.ui-form-grid.compact) {
grid-template-columns: 1fr;
}
}
.app-shell {
display: grid;
grid-template-columns: 252px minmax(0, 1fr);
@@ -1,15 +1,11 @@
import {
Boxes,
Calculator,
ClipboardList,
DollarSign,
ClipboardPenLine,
FlaskConical,
Gauge,
LayoutDashboard,
ShieldCheck,
TrendingUp,
Wheat,
Workflow
TrendingUp
} from 'lucide-svelte';
import type { ComponentType } from 'svelte';
@@ -63,6 +59,15 @@ export const mixCalculatorItem: NavItem = {
moduleKey: 'mix_calculator'
};
export const editorItem: NavItem = {
href: '/editor',
label: 'Editor',
shortLabel: 'ED',
icon: ClipboardPenLine,
moduleKey: 'products',
badge: 'test'
};
export const reportingItem: NavItem = {
href: '/reporting',
label: 'Reporting',
@@ -81,10 +86,7 @@ export const throughputItem: NavItem = {
};
export const workingDocumentItems: NavItem[] = [
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
{ href: '/products', label: 'Products', shortLabel: 'PR', icon: Boxes, moduleKey: 'products' },
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', icon: Workflow, moduleKey: 'scenarios' }
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' }
];
export const accessControlItem: NavItem = {
@@ -99,28 +101,25 @@ export const clientNavigationItems: NavItem[] = [
dashboardItem,
mixCalculatorItem,
throughputItem,
...workingDocumentItems,
editorItem,
accessControlItem
];
export const footerLinks: FooterLink[] = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP', icon: DollarSign },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV', icon: ClipboardList }
];
export const footerLinks: FooterLink[] = [];
export const baseSearchItems: SearchItem[] = [
{
href: '/editor',
label: 'Open Editor',
description: 'Edit client, product, and mix naming from one table.',
keywords: 'editor products mixes clients names bulk table phf horse manning'
},
{
href: '/',
label: 'Open Dashboard',
description: 'Jump to the Hunter Premium Produce workspace summary.',
keywords: 'hunter premium produce overview dashboard workspace home'
},
{
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',
@@ -149,12 +148,6 @@ export const baseSearchItems: SearchItem[] = [
description: 'Run a new client-specific mix calculation session.',
keywords: 'new mix calculator session client batch size product bags print'
},
{
href: '/products',
label: 'Open Products',
description: 'Review delivered product pricing and margins.',
keywords: 'products pricing margins delivered outputs'
},
{
href: '/reporting',
label: 'Open Reporting',
@@ -167,12 +160,6 @@ export const baseSearchItems: SearchItem[] = [
description: 'Review account details and workspace preferences.',
keywords: 'settings account preferences profile workspace'
},
{
href: '/scenarios',
label: 'Open Scenarios',
description: 'Inspect planning scenarios and overrides.',
keywords: 'scenarios sandbox overrides compare planning'
}
];
export function matchesRoute(href: string, pathname: string) {
@@ -198,6 +185,10 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
return [...crumbs, { label: 'Mix Calculator' }];
}
if (pathname.startsWith('/editor')) {
return [...crumbs, { label: 'Editor' }];
}
if (pathname.startsWith('/mixes')) {
return [...crumbs, { label: 'Mix Master' }];
}
+33
View File
@@ -208,6 +208,39 @@ export type ProductCostBreakdown = {
inputs?: Record<string, unknown>;
};
export type EditorProductRow = {
id: number;
tenant_id: string;
client_name: string;
item_id: string | null;
name: string;
mix_id: number;
mix_client_name: string;
mix_name: string;
sale_type: string;
unit_of_measure: string;
visible: boolean;
product_notes: string | null;
mix_notes: string | null;
};
export type EditorProductUpdateInput = {
client_name?: string;
item_id?: string | null;
name?: string;
mix_id?: number;
sale_type?: string;
unit_of_measure?: string;
visible?: boolean;
notes?: string | null;
};
export type EditorMixUpdateInput = {
client_name?: string;
name?: string;
notes?: string | null;
};
export type Scenario = {
id: number;
name: string;
+61 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { canAccessRoute, getDefaultRouteForRole, getWorkspaceRole } from './workspace-access';
import { canAccessRoute, canOpenEditor, getDefaultRouteForRole, getWorkspaceRole } from './workspace-access';
describe('workspace access policy', () => {
const operationsSession = {
@@ -21,6 +21,51 @@ describe('workspace access policy', () => {
token: 'token'
};
const fullAccessSession = {
role: 'internal',
role_name: 'Full Access',
permissions: ['edit_products', 'edit_mixes'],
name: 'Full User',
email: 'full@example.com',
token: 'token'
};
const leanSession = {
role: 'internal',
role_name: 'lean',
permissions: [
'view_dashboard',
'edit_products',
'edit_mixes',
'edit_scenarios',
'manage_client_access',
'view_settings'
],
module_permissions: {
dashboard: 'view',
products: 'edit',
mix_master: 'edit',
scenarios: 'edit',
client_access: 'manage'
},
name: 'Lean User',
email: 'lean@example.com',
token: 'token'
};
const ownerSession = {
role: 'client',
client_role: 'superadmin',
module_permissions: {
products: 'edit',
mix_master: 'edit',
client_access: 'manage'
},
name: 'Owner User',
email: 'owner@example.com',
token: 'token'
};
it('classifies operations users and sends them to mix calculator by default', () => {
expect(getWorkspaceRole(operationsSession)).toBe('operations');
expect(getDefaultRouteForRole(operationsSession)).toBe('/mix-calculator');
@@ -35,4 +80,19 @@ describe('workspace access policy', () => {
expect(getWorkspaceRole(adminSession)).toBe('admin');
expect(canAccessRoute(adminSession, '/')).toBe(true);
});
it('treats lean users as owner-level internal admins', () => {
expect(getWorkspaceRole(leanSession)).toBe('admin');
expect(canOpenEditor(leanSession)).toBe(true);
expect(canAccessRoute(leanSession, '/scenarios')).toBe(true);
expect(canAccessRoute(leanSession, '/client-access')).toBe(true);
});
it('limits editor access to internal admin sessions', () => {
expect(canOpenEditor(adminSession)).toBe(true);
expect(canOpenEditor(ownerSession)).toBe(false);
expect(canOpenEditor(fullAccessSession)).toBe(false);
expect(canAccessRoute(ownerSession, '/editor')).toBe(false);
expect(canAccessRoute(fullAccessSession, '/editor')).toBe(false);
});
});
+16 -1
View File
@@ -29,6 +29,10 @@ function canAccessWorkspaceArea(
return hasModuleAccess(session, moduleKey, minimumLevel);
}
function isLeanAdminRole(session: AppSession | null | undefined) {
return session?.role === 'admin' || (session?.role === 'internal' && ['admin', 'lean'].includes(session.role_name?.toLowerCase() ?? ''));
}
export function getWorkspaceRole(session: AppSession | null | undefined): WorkspaceRole {
if (!session) {
return 'unknown';
@@ -42,7 +46,7 @@ export function getWorkspaceRole(session: AppSession | null | undefined): Worksp
return 'client';
}
if (session.role_name === 'Admin') {
if (isLeanAdminRole(session)) {
return 'admin';
}
@@ -85,6 +89,14 @@ export function canOpenProducts(session: AppSession | null | undefined) {
return canAccessWorkspaceArea(session, 'products', ['view_products', 'edit_products']);
}
export function canOpenEditor(session: AppSession | null | undefined) {
if (!session) {
return false;
}
return isLeanAdminRole(session);
}
export function canOpenScenarios(session: AppSession | null | undefined) {
return !!session && hasModuleAccess(session, 'scenarios');
}
@@ -129,6 +141,7 @@ export const routeAccessRules: RouteAccessRule[] = [
},
{ path: '/mixes', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/mixes') },
{ path: '/products', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/products') },
{ path: '/editor', roles: ['admin'], matches: (pathname) => hasPathPrefix(pathname, '/editor') },
{ path: '/reporting', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/reporting') },
{ path: '/scenarios', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/scenarios') },
{
@@ -184,6 +197,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
if (pathname.startsWith('/raw-materials')) return canOpenRawMaterials(session);
if (pathname.startsWith('/mixes')) return canOpenMixMaster(session);
if (pathname.startsWith('/products')) return canOpenProducts(session);
if (pathname.startsWith('/editor')) return canOpenEditor(session);
if (pathname.startsWith('/scenarios')) return canOpenScenarios(session);
if (pathname.startsWith('/reporting')) return canOpenReporting(session);
if (pathname.startsWith('/settings')) return canOpenSettings(session);
@@ -197,6 +211,7 @@ export function canUseWorkspaceSearch(session: AppSession | null | undefined) {
canOpenDashboard(session) ||
canOpenRawMaterials(session) ||
canOpenMixMaster(session) ||
canOpenEditor(session) ||
canOpenMixCalculator(session) ||
canOpenProducts(session) ||
canOpenScenarios(session)
+4 -2
View File
@@ -4,7 +4,7 @@
import { clientSession, sessionHydrated } from '$lib/session';
import Skeleton from '$lib/components/Skeleton.svelte';
import type { DashboardSummary } from '$lib/types';
import { getWorkspaceHomeHref } from '$lib/workspace-access';
import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access';
import packageInfo from '../../package.json';
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
import { tick } from 'svelte';
@@ -464,7 +464,9 @@
<h2>{greeting.label}, {firstName($clientSession?.name)}</h2>
</div>
<div class="intro-actions">
<a class="primary-button" href="/products">Review Delivered Pricing</a>
{#if canOpenEditor($clientSession)}
<a class="primary-button" href="/editor">Open Editor</a>
{/if}
</div>
</section>
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { rows: [] };
}
const session = getStoredClientSession();
if (!canOpenEditor(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [rows, rawMaterials] = await Promise.all([
api.editorProducts({ limit: 1000 }, fetch),
api.rawMaterials(fetch)
]);
return {
rows,
rawMaterials
};
} catch {
return { rows: [], rawMaterials: [] };
}
}
+19 -299
View File
@@ -105,40 +105,40 @@
</script>
<section class="page-actions">
<a class="primary-button" href="/mixes/new">New Mix Worksheet</a>
<a class="ui-button primary" href="/mixes/new">New Mix Worksheet</a>
</section>
<section class="metric-row">
<article class="metric-card">
<section class="ui-metric-row module-section">
<article class="ui-metric-card">
<span>Total Mixes</span>
<strong>{data.mixes.length}</strong>
<p>Saved mix definitions</p>
</article>
<article class="metric-card">
<article class="ui-metric-card">
<span>Average Cost / Kg</span>
<strong>{currency(averageCost, 4)}</strong>
<p>Across all saved mixes</p>
</article>
<article class="metric-card">
<article class="ui-metric-card">
<span>Warnings</span>
<strong>{warningCount}</strong>
<p>Mixes needing review</p>
</article>
</section>
<section class="table-card">
<div class="section-heading">
<section class="ui-panel module-section">
<div class="ui-section-heading">
<div>
<p class="eyebrow">Table View</p>
<p class="ui-eyebrow">Table View</p>
<h3>Saved mixes</h3>
</div>
<span class="soft-pill">Open any mix to edit</span>
<span class="ui-pill positive">Open any mix to edit</span>
</div>
<div class="table-wrap">
<table>
<div class="ui-table-wrap">
<table class="ui-table">
<thead>
<tr>
<th>Mix</th>
@@ -155,8 +155,8 @@
{#each data.mixes as mix}
<tr>
<td data-label="Mix">
<div class="table-item">
<span class="row-badge">MX</span>
<div class="ui-table-identity">
<span class="ui-row-mark">MX</span>
<div>
<strong>{mix.name}</strong>
<span>v{mix.version ?? 1}</span>
@@ -169,14 +169,14 @@
<td data-label="Total Cost">{currency(mix.total_mix_cost)}</td>
<td data-label="Cost / Kg">{currency(mix.mix_cost_per_kg, 4)}</td>
<td data-label="Status">
<span class={`status-pill ${mix.warnings.length ? 'warning' : 'positive'}`}>{mix.status}</span>
<span class={`ui-pill ${mix.warnings.length ? 'warning' : 'positive'}`}>{mix.status}</span>
</td>
<td class="menu-cell" data-label="Actions">
<div class="menu-wrap">
<button
aria-expanded={activeMenuId === mix.id}
aria-haspopup="menu"
class="menu-trigger"
class="ui-button secondary menu-trigger"
type="button"
onclick={(event) => toggleMenu(mix.id, event)}
>
@@ -199,218 +199,21 @@
</section>
<style>
h2,
h3,
p {
margin: 0;
}
.eyebrow {
color: #7f8e85;
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-actions,
.metric-row,
.table-card {
.module-section {
margin-bottom: 1.12rem;
}
.page-actions {
margin-bottom: 1.12rem;
display: flex;
justify-content: flex-end;
}
.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: var(--color-brand);
box-shadow: none;
}
.metric-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
}
.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 {
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%;
min-width: 48rem;
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: var(--color-brand);
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%;
}
@@ -421,16 +224,8 @@
}
.menu-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line-strong);
border-radius: 0.76rem;
padding: 0.6rem 0.74rem;
color: #304038;
background: #fff;
font-weight: 600;
cursor: pointer;
min-height: 2.25rem;
padding: 0.52rem 0.72rem;
}
.menu-panel {
@@ -456,82 +251,7 @@
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;
}
.table-card {
padding: 1rem;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
table {
min-width: 0;
border-spacing: 0;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.9rem;
}
tbody tr {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
tbody td {
padding: 0.78rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
tbody td:first-child,
tbody td:last-child {
border: none;
border-radius: 0;
}
tbody td + td {
border-top: 1px solid var(--line);
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.menu-wrap,
.menu-trigger {
width: 100%;
+42 -42
View File
@@ -264,20 +264,20 @@
<div class="panel-body">
{#if activeView === 'overview'}
<section class="metric-row">
<article class="metric-card">
<section class="ui-metric-row metric-row">
<article class="ui-metric-card metric-card">
<span>Total Spend Tracked</span>
<strong>{currency(totalSpend)}</strong>
<p>Across current market values</p>
</article>
<article class="metric-card">
<article class="ui-metric-card metric-card">
<span>Average Waste</span>
<strong>{(averageWaste * 100).toFixed(1)}%</strong>
<p>Current blended input loss</p>
</article>
<article class="metric-card">
<article class="ui-metric-card metric-card">
<span>Latest Price Update</span>
<strong>{formatDate(latestEffectiveDate)}</strong>
<p>Most recent effective date on file</p>
@@ -286,10 +286,10 @@
<section class="top-grid">
<div class="summary-stack">
<article class="surface-card">
<div class="section-heading">
<article class="ui-panel surface-card">
<div class="ui-section-heading section-heading">
<div>
<p class="eyebrow">Downstream Snapshot</p>
<p class="ui-eyebrow eyebrow">Downstream Snapshot</p>
<h3>Mixes affected by current inputs</h3>
</div>
</div>
@@ -318,10 +318,10 @@
{/if}
</article>
<article class="surface-card">
<div class="section-heading">
<article class="ui-panel surface-card">
<div class="ui-section-heading section-heading">
<div>
<p class="eyebrow">Product Exposure</p>
<p class="ui-eyebrow eyebrow">Product Exposure</p>
<h3>Finished outputs linked to live pricing</h3>
</div>
</div>
@@ -354,53 +354,53 @@
{:else if activeView === 'create'}
<section class="top-grid create-grid">
<article class="surface-card form-card">
<div class="section-heading">
<article class="ui-panel surface-card form-card">
<div class="ui-section-heading section-heading">
<div>
<p class="eyebrow">Create Input</p>
<p class="ui-eyebrow eyebrow">Create Input</p>
<h3>Add a new raw material</h3>
</div>
<span class="soft-pill">Live costing source</span>
<span class="ui-pill positive soft-pill">Live costing source</span>
</div>
<form class="material-form" onsubmit={handleCreateMaterial}>
<div class="form-grid">
<label>
<div class="ui-form-grid form-grid">
<label class="ui-field">
Name
<input name="name" required />
</label>
<label>
<label class="ui-field">
Supplier
<input name="supplier" />
</label>
<label>
<label class="ui-field">
Unit of measure
<input name="unit_of_measure" value="tonne" required />
</label>
<label>
<label class="ui-field">
Kg per unit
<input name="kg_per_unit" type="number" min="0.0001" step="0.0001" value="1000" required />
</label>
<label>
<label class="ui-field">
Market value
<input name="market_value" type="number" min="0.0001" step="0.0001" required />
</label>
<label>
<label class="ui-field">
Waste percentage
<input name="waste_percentage" type="number" min="0" max="1" step="0.0001" value="0" required />
</label>
<label>
<label class="ui-field">
Effective date
<input name="effective_date" type="date" value={today} required />
</label>
<label>
<label class="ui-field">
Status
<select name="status">
<option value="active">Active</option>
@@ -410,29 +410,29 @@
</label>
</div>
<div class="form-grid single">
<label>
<div class="ui-form-grid form-grid single">
<label class="ui-field">
Material notes
<textarea name="notes" rows="3"></textarea>
</label>
<label>
<label class="ui-field">
Price notes
<textarea name="price_notes" rows="3"></textarea>
</label>
</div>
<button class="primary-button" type="submit" disabled={isCreating}>
<button class="ui-button primary primary-button" type="submit" disabled={isCreating}>
{isCreating ? 'Creating material...' : 'Create raw material'}
</button>
</form>
</article>
<div class="summary-stack">
<article class="surface-card mini-metric-card">
<div class="section-heading">
<article class="ui-panel surface-card mini-metric-card">
<div class="ui-section-heading section-heading">
<div>
<p class="eyebrow">Portfolio Health</p>
<p class="ui-eyebrow eyebrow">Portfolio Health</p>
<h3>Current input coverage</h3>
</div>
</div>
@@ -470,7 +470,7 @@
{@const impactedMixes = getImpactedMixes(material.id)}
{@const impactedProducts = getImpactedProducts(material.id)}
<article class="surface-card material-card">
<article class="ui-panel material-card">
<div class="material-header">
<div class="material-title">
<span class={`material-icon ${material.status === 'active' ? 'active' : 'muted'}`}>RM</span>
@@ -480,7 +480,7 @@
</div>
</div>
<span class={`status-pill ${material.status === 'active' ? 'positive' : 'neutral'}`}>{material.status}</span>
<span class={`ui-pill ${material.status === 'active' ? 'positive' : 'neutral'}`}>{material.status}</span>
</div>
<div class="material-grid">
@@ -505,37 +505,37 @@
</article>
</section>
<form class="price-card" onsubmit={(event) => handleAddPrice(event, material.id)}>
<div class="section-heading">
<form class="ui-panel-soft price-card" onsubmit={(event) => handleAddPrice(event, material.id)}>
<div class="ui-section-heading section-heading">
<div>
<p class="eyebrow">New Version</p>
<p class="ui-eyebrow eyebrow">New Version</p>
<h4>Record a fresh price</h4>
</div>
</div>
<div class="form-grid compact">
<label>
<div class="ui-form-grid compact form-grid">
<label class="ui-field">
Market value
<input name="market_value" type="number" min="0.0001" step="0.0001" required />
</label>
<label>
<label class="ui-field">
Waste percentage
<input name="waste_percentage" type="number" min="0" max="1" step="0.0001" value="0" required />
</label>
<label>
<label class="ui-field">
Effective date
<input name="effective_date" type="date" value={today} required />
</label>
</div>
<label>
<label class="ui-field">
Notes
<textarea name="notes" rows="2"></textarea>
</label>
<button class="primary-button" type="submit" disabled={pendingMaterialId === material.id}>
<button class="ui-button primary primary-button" type="submit" disabled={pendingMaterialId === material.id}>
{pendingMaterialId === material.id ? 'Saving price...' : 'Save price version'}
</button>
</form>
@@ -592,7 +592,7 @@
{/each}
{#if data.rawMaterials.length > pageSize}
<div class="pagination surface-card library-pagination">
<div class="pagination ui-panel library-pagination">
<span class="pagination-summary">Showing {Math.min((materialLibraryPage - 1) * pageSize + 1, data.rawMaterials.length)}-{Math.min(materialLibraryPage * pageSize, data.rawMaterials.length)} of {data.rawMaterials.length}</span>
<div class="pagination-actions">
<button type="button" class="pagination-button" onclick={() => (materialLibraryPage -= 1)} disabled={materialLibraryPage === 1}>Previous</button>
+89 -218
View File
@@ -11,270 +11,141 @@
const approvedCount = $derived(scenarioRows.filter((scenario) => scenario.status === 'approved').length);
</script>
<section class="metric-row">
<article class="metric-card">
<section class="ui-metric-row module-section">
<article class="ui-metric-card">
<span>Total Scenarios</span>
<strong>{scenarioRows.length}</strong>
<p>Saved planning workspaces</p>
</article>
<article class="metric-card">
<article class="ui-metric-card">
<span>Approved</span>
<strong>{approvedCount}</strong>
<p>Ready for pricing reference</p>
</article>
<article class="metric-card">
<article class="ui-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>
<section class="ui-panel module-section">
<div class="ui-section-heading">
<div>
<p class="ui-eyebrow">Scenario Library</p>
<h3>Planning scenarios</h3>
</div>
<span class="ui-pill neutral">{scenarioRows.length} saved</span>
</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}
<div class="ui-table-wrap">
<table class="ui-table scenario-table">
<thead>
<tr>
<th>Scenario</th>
<th>Status</th>
<th>Overrides</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
{#each scenarioRows as scenario}
<tr>
<td data-label="Scenario">
<div class="ui-table-identity scenario-identity">
<span class="ui-row-mark">SC</span>
<div>
<strong>{scenario.name}</strong>
<span>{scenario.description ?? 'No description provided yet.'}</span>
</div>
</div>
</td>
<td data-label="Status">
<span class={`ui-pill ${scenario.status === 'approved' ? 'positive' : 'neutral'}`}>{scenario.status}</span>
</td>
<td data-label="Overrides">
{#if scenario.overrideKeys.length}
<div class="chip-list">
{#each scenario.overrideKeys as key}
<span>{key}</span>
{/each}
</div>
{:else}
<span class="ui-muted">No overrides</span>
{/if}
</td>
<td data-label="Payload">
<details class="payload-details">
<summary>View JSON</summary>
<pre>{JSON.stringify(scenario.overrides, null, 2)}</pre>
</details>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<style>
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 {
.module-section {
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;
}
.surface-card {
padding: 1.2rem;
display: grid;
gap: 1rem;
}
.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;
gap: 0.42rem;
max-width: 28rem;
}
.chip-list span {
padding: 0.45rem 0.7rem;
padding: 0.36rem 0.62rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
color: #365044;
font-size: 0.84rem;
color: var(--color-text-secondary);
font-size: 0.8rem;
font-weight: 600;
}
.json-header {
margin-bottom: 0.8rem;
.scenario-table {
min-width: 58rem;
}
.json-header h4 {
font-size: 1rem;
.scenario-identity {
min-width: 22rem;
}
.payload-details {
max-width: 24rem;
}
.payload-details summary {
width: fit-content;
color: var(--green-deep);
font-weight: 700;
cursor: pointer;
}
pre {
padding: 1rem;
border-radius: 1rem;
background: var(--panel-soft);
border: 1px solid var(--line);
.payload-details pre {
max-height: 14rem;
margin-top: 0.7rem;
padding: 0.9rem;
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;
}
border: 1px solid var(--line);
border-radius: 0.82rem;
background: var(--panel);
color: var(--muted);
font-size: 0.8rem;
line-height: 1.5;
}
</style>