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 ./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`. 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]: def _authorized_client_scope(db: Session, session: AuthSession) -> list[ClientAccount]:
clients = list_client_accounts(db) clients = list_client_accounts(db)
if session.role == "admin": if session.role in {"admin", "internal"}:
return clients return clients
return [client for client in clients if client.id == session.client_account_id] 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]: def _actor_metadata(session: AuthSession) -> dict[str, str]:
if session.role == "admin": if session.role in {"admin", "internal"}:
return { return {
"actor_type": "lean_admin", "actor_type": "lean_admin",
"actor_name": session.name, "actor_name": session.name,
"actor_email": session.email, "actor_email": session.email,
"actor_role": "admin", "actor_role": session.client_role or "admin",
} }
return { return {
"actor_type": "client_superadmin", "actor_type": "client_superadmin",
+6
View File
@@ -190,6 +190,12 @@ def require_client_access_manager_session(
) -> AuthSession: ) -> AuthSession:
if session.role == "admin": if session.role == "admin":
return session 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": if session.role != "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access management requires admin or superadmin access") 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"), "edit_mixes": ("mix_master", "edit"),
"view_throughput": ("operations_throughput", "view"), "view_throughput": ("operations_throughput", "view"),
"edit_throughput": ("operations_throughput", "edit"), "edit_throughput": ("operations_throughput", "edit"),
# Admin-only permissions (view_users, manage_users, manage_permissions, "view_scenarios": ("scenarios", "view"),
# view_settings, edit_settings) are intentionally excluded — they don't "edit_scenarios": ("scenarios", "edit"),
# correspond to any of the legacy module keys and remain accessible only "manage_client_access": ("client_access", "manage"),
# via the explicit `require_permission(...)` dependency. # 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} _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.auth import router as auth_router
from app.api.client_access import router as client_access_router from app.api.client_access import router as client_access_router
from app.api.dashboard import router as dashboard_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.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_router from app.api.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_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(access_router)
app.include_router(client_access_router) app.include_router(client_access_router)
app.include_router(dashboard_router) app.include_router(dashboard_router)
app.include_router(editor_router)
app.include_router(raw_materials_router) app.include_router(raw_materials_router)
app.include_router(mixes_router) app.include_router(mixes_router)
app.include_router(mix_calculator_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"), ("edit_mixes", "Create and edit mix master recipes"),
("view_throughput", "View operations throughput"), ("view_throughput", "View operations throughput"),
("edit_throughput", "Create and edit operations throughput entries"), ("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"), ("view_users", "View internal users and roles"),
("manage_users", "Create, deactivate, and assign user roles"), ("manage_users", "Create, deactivate, and assign user roles"),
("manage_permissions", "Modify roles and role-permission assignments"), ("manage_permissions", "Modify roles and role-permission assignments"),
@@ -50,10 +53,14 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"view_raw_materials", "view_raw_materials",
"edit_raw_materials", "edit_raw_materials",
"view_products", "view_products",
"edit_products",
"view_mixes", "view_mixes",
"edit_mixes", "edit_mixes",
"view_throughput", "view_throughput",
"edit_throughput", "edit_throughput",
"view_scenarios",
"edit_scenarios",
"manage_client_access",
"view_users", "view_users",
"manage_users", "manage_users",
"manage_permissions", "manage_permissions",
@@ -88,6 +95,31 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_throughput", "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 ( from app.core.access import (
INTERNAL_USER_SUBJECT, INTERNAL_USER_SUBJECT,
get_user_permissions, get_user_permissions,
permissions_to_module_map,
require_all_permissions, require_all_permissions,
require_any_permission, require_any_permission,
require_permission, require_permission,
@@ -72,9 +73,17 @@ def test_admin_role_permissions_match_spec():
assert granted == set(ROLE_DEFINITIONS["Admin"]["permissions"]) assert granted == set(ROLE_DEFINITIONS["Admin"]["permissions"])
assert "manage_users" in granted assert "manage_users" in granted
assert "manage_permissions" in granted assert "manage_permissions" in granted
# Admin spec deliberately excludes edit_products / edit_mixes. assert "edit_products" in granted
assert "edit_products" not in granted assert "edit_mixes" in granted
assert "edit_mixes" not 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(): 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 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(): def test_inactive_user_has_no_permissions():
db = _build_session() db = _build_session()
seed_access(db) seed_access(db)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.8", "version": "0.1.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.8", "version": "0.1.11",
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1" "lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.9", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+22
View File
@@ -19,6 +19,9 @@ import type {
ClientUserModulePermission, ClientUserModulePermission,
ClientUserUpdateInput, ClientUserUpdateInput,
LoginResponse, LoginResponse,
EditorMixUpdateInput,
EditorProductRow,
EditorProductUpdateInput,
MixCalculatorCreateInput, MixCalculatorCreateInput,
MixCalculatorOptions, MixCalculatorOptions,
MixCalculatorPreview, MixCalculatorPreview,
@@ -318,6 +321,25 @@ export const api = {
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher), 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) => productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher), cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher), scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
+391 -45
View File
@@ -14,12 +14,10 @@
canCreateMixWorksheet as sessionCanCreateMixWorksheet, canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenClientAccess as sessionCanOpenClientAccess, canOpenClientAccess as sessionCanOpenClientAccess,
canOpenDashboard as sessionCanOpenDashboard, canOpenDashboard as sessionCanOpenDashboard,
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator, canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster, canOpenMixMaster as sessionCanOpenMixMaster,
canOpenProducts as sessionCanOpenProducts,
canOpenRawMaterials as sessionCanOpenRawMaterials,
canOpenReporting as sessionCanOpenReporting, canOpenReporting as sessionCanOpenReporting,
canOpenScenarios as sessionCanOpenScenarios,
canOpenSettings as sessionCanOpenSettings, canOpenSettings as sessionCanOpenSettings,
canOpenThroughput as sessionCanOpenThroughput, canOpenThroughput as sessionCanOpenThroughput,
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch, canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
@@ -32,6 +30,7 @@
baseSearchItems, baseSearchItems,
clientBreadcrumbs, clientBreadcrumbs,
dashboardItem, dashboardItem,
editorItem,
footerLinks, footerLinks,
matchesRoute, matchesRoute,
mixCalculatorItem, mixCalculatorItem,
@@ -47,11 +46,11 @@
import packageInfo from '../../../package.json'; import packageInfo from '../../../package.json';
import { import {
Calculator, Calculator,
DollarSign,
Search, Search,
LogOut, LogOut,
Plus, Plus,
Menu Menu,
Settings
} from 'lucide-svelte'; } from 'lucide-svelte';
let { children } = $props(); let { children } = $props();
@@ -71,13 +70,11 @@
const appVersion = `v${packageInfo.version}`; const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession)); const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
const canOpenRawMaterials = $derived(sessionCanOpenRawMaterials($clientSession));
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession)); const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession)); const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession)); const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession)); const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
const canOpenProducts = $derived(sessionCanOpenProducts($clientSession)); const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
const canOpenScenarios = $derived(sessionCanOpenScenarios($clientSession));
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession)); const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
const canOpenClientAccess = $derived(sessionCanOpenClientAccess($clientSession)); const canOpenClientAccess = $derived(sessionCanOpenClientAccess($clientSession));
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession)); const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
@@ -94,10 +91,7 @@
!$clientSession !$clientSession
? workingDocumentItems ? workingDocumentItems
: workingDocumentItems.filter((item) => { : workingDocumentItems.filter((item) => {
if (item.href === '/raw-materials') return canOpenRawMaterials;
if (item.href === '/mixes') return canOpenMixMaster; 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); return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
}) })
); );
@@ -105,6 +99,7 @@
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession)); const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null); const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null); const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations'); const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession)); const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([ const visibleFooterLinks = $derived([
@@ -126,14 +121,12 @@
const visibleBaseSearchItems = $derived( const visibleBaseSearchItems = $derived(
baseSearchItems.filter((item) => { baseSearchItems.filter((item) => {
if (item.href === '/') return canOpenDashboard; if (item.href === '/') return canOpenDashboard;
if (item.href === '/raw-materials') return canOpenRawMaterials;
if (item.href === '/mixes') return canOpenMixMaster; if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/mixes/new') return canCreateMixWorksheet; if (item.href === '/mixes/new') return canCreateMixWorksheet;
if (item.href === '/mix-calculator') return canOpenMixCalculator; 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 === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings; if (item.href === '/settings') return canOpenSettings;
if (item.href === '/scenarios') return canOpenScenarios;
return true; return true;
}) })
); );
@@ -266,24 +259,17 @@
seededSearchKey = sessionKey; seededSearchKey = sessionKey;
Promise.all([ Promise.all([
sessionCanOpenProducts(session) ? api.products() : Promise.resolve([]),
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]), sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session) featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
? api.mixCalculatorSessions() ? api.mixCalculatorSessions()
: Promise.resolve([]) : Promise.resolve([])
]) ])
.then(([products, mixes, sessions]) => { .then(([mixes, sessions]) => {
if (seededSearchKey !== sessionKey) { if (seededSearchKey !== sessionKey) {
return; return;
} }
seededSearchItems = [ 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) => ({ ...mixes.map((mix) => ({
href: `/mixes/${mix.id}`, href: `/mixes/${mix.id}`,
label: mix.name, label: mix.name,
@@ -394,6 +380,7 @@
primaryItems={[ primaryItems={[
...(visibleDashboardItem ? [visibleDashboardItem] : []), ...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []), ...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleThroughputItem ? [visibleThroughputItem] : []), ...(visibleThroughputItem ? [visibleThroughputItem] : []),
...(visibleReportingItem ? [visibleReportingItem] : []) ...(visibleReportingItem ? [visibleReportingItem] : [])
]} ]}
@@ -458,16 +445,13 @@
{#if canCreateMixSession} {#if canCreateMixSession}
<a href="/mix-calculator">Create mix session</a> <a href="/mix-calculator">Create mix session</a>
{/if} {/if}
{#if canOpenProducts}
<a href="/products">Review delivered pricing</a>
{/if}
{#if canUseWorkspaceSearch} {#if canUseWorkspaceSearch}
<button type="button" onclick={() => openPalette('')}>Search the workspace</button> <button type="button" onclick={() => openPalette('')}>Search the workspace</button>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canOpenProducts || canUseWorkspaceSearch} {#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canUseWorkspaceSearch}
<button <button
aria-expanded={quickMenuOpen} aria-expanded={quickMenuOpen}
aria-label="Open quick access menu" aria-label="Open quick access menu"
@@ -553,6 +537,15 @@
</a> </a>
{/if} {/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} {#if visibleReportingItem}
{@const Icon = visibleReportingItem.icon} {@const Icon = visibleReportingItem.icon}
<a class:active={matchesRoute(visibleReportingItem.href, page.url.pathname)} href={visibleReportingItem.href} onclick={() => (navOpen = false)}> <a class:active={matchesRoute(visibleReportingItem.href, page.url.pathname)} href={visibleReportingItem.href} onclick={() => (navOpen = false)}>
@@ -593,12 +586,6 @@
<span>Change settings</span> <span>Change settings</span>
</button> </button>
{/if} {/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} {#if canUseWorkspaceSearch}
<button type="button" onclick={() => openPalette('')}> <button type="button" onclick={() => openPalette('')}>
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span> <span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
@@ -645,7 +632,7 @@
> >
<div class="palette-input-row"> <div class="palette-input-row">
<span class="search-icon"></span> <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> <kbd>Esc</kbd>
</div> </div>
@@ -663,7 +650,7 @@
{:else} {:else}
<div class="palette-empty"> <div class="palette-empty">
<strong>No results</strong> <strong>No results</strong>
<span>Try searching for mixes, products, scenarios, or pricing.</span> <span>Try searching for mixes, sessions, or pages.</span>
</div> </div>
{/if} {/if}
</div> </div>
@@ -674,27 +661,31 @@
<style> <style>
:global(:root) { :global(:root) {
/* ── Brand ──────────────────────────────────────────────── */ /* ── Brand ──────────────────────────────────────────────── */
--color-brand: #15803d; --color-brand: oklch(0.54 0.15 149);
--color-brand-tint: #f0fdf4; --color-brand-hover: oklch(0.47 0.14 149);
--color-brand-tint: oklch(0.98 0.02 149);
/* ── Surfaces ───────────────────────────────────────────── */ /* ── Surfaces ───────────────────────────────────────────── */
--color-bg-app: #f6f8fa; --color-bg-app: oklch(0.975 0.006 150);
--color-bg-surface: #ffffff; --color-bg-surface: oklch(0.997 0.004 150);
--color-bg-elevated: oklch(0.99 0.005 150);
/* ── Borders ────────────────────────────────────────────── */ /* ── Borders ────────────────────────────────────────────── */
--color-border: #e1e4e8; --color-border: oklch(0.905 0.012 150);
--color-divider: #eaecef; --color-divider: oklch(0.935 0.009 150);
/* ── Text ───────────────────────────────────────────────── */ /* ── Text ───────────────────────────────────────────────── */
--color-text-primary: #24292f; --color-text-primary: oklch(0.26 0.015 150);
--color-text-secondary: #57606a; --color-text-secondary: oklch(0.44 0.018 150);
--color-text-muted: #8b949e; --color-text-muted: oklch(0.62 0.018 150);
/* ── Semantic ───────────────────────────────────────────── */ /* ── Semantic ───────────────────────────────────────────── */
--color-success: #1a7f37; --color-success: #1a7f37;
--color-warning: #bf8700; --color-warning: #bf8700;
--color-error: #cf222e; --color-error: #cf222e;
--color-info: #0969da; --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) ───────── */ /* ── Legacy aliases (keep old token names working) ───────── */
--bg: var(--color-bg-app); --bg: var(--color-bg-app);
@@ -705,10 +696,15 @@
--text: var(--color-text-primary); --text: var(--color-text-primary);
--muted: var(--color-text-muted); --muted: var(--color-text-muted);
--green: var(--color-brand); --green: var(--color-brand);
--green-deep: #1a1f1c; --green-deep: oklch(0.25 0.018 150);
--green-soft: var(--color-brand-tint); --green-soft: var(--color-brand-tint);
--blue-soft: #e8f4ff; --blue-soft: var(--color-info-tint);
--shadow: none; /* flat design — use borders, not shadows */ --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) { :global(html, body) {
@@ -741,6 +737,356 @@
text-decoration: none; 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 { .app-shell {
display: grid; display: grid;
grid-template-columns: 252px minmax(0, 1fr); grid-template-columns: 252px minmax(0, 1fr);
@@ -1,15 +1,11 @@
import { import {
Boxes,
Calculator, Calculator,
ClipboardList, ClipboardPenLine,
DollarSign,
FlaskConical, FlaskConical,
Gauge, Gauge,
LayoutDashboard, LayoutDashboard,
ShieldCheck, ShieldCheck,
TrendingUp, TrendingUp
Wheat,
Workflow
} from 'lucide-svelte'; } from 'lucide-svelte';
import type { ComponentType } from 'svelte'; import type { ComponentType } from 'svelte';
@@ -63,6 +59,15 @@ export const mixCalculatorItem: NavItem = {
moduleKey: 'mix_calculator' moduleKey: 'mix_calculator'
}; };
export const editorItem: NavItem = {
href: '/editor',
label: 'Editor',
shortLabel: 'ED',
icon: ClipboardPenLine,
moduleKey: 'products',
badge: 'test'
};
export const reportingItem: NavItem = { export const reportingItem: NavItem = {
href: '/reporting', href: '/reporting',
label: 'Reporting', label: 'Reporting',
@@ -81,10 +86,7 @@ export const throughputItem: NavItem = {
}; };
export const workingDocumentItems: 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: '/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' }
]; ];
export const accessControlItem: NavItem = { export const accessControlItem: NavItem = {
@@ -99,28 +101,25 @@ export const clientNavigationItems: NavItem[] = [
dashboardItem, dashboardItem,
mixCalculatorItem, mixCalculatorItem,
throughputItem, throughputItem,
...workingDocumentItems, editorItem,
accessControlItem accessControlItem
]; ];
export const footerLinks: FooterLink[] = [ export const footerLinks: FooterLink[] = [];
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP', icon: DollarSign },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV', icon: ClipboardList }
];
export const baseSearchItems: SearchItem[] = [ 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: '/', href: '/',
label: 'Open Dashboard', label: 'Open Dashboard',
description: 'Jump to the Hunter Premium Produce workspace summary.', description: 'Jump to the Hunter Premium Produce workspace summary.',
keywords: 'hunter premium produce overview dashboard workspace home' 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', href: '/mixes',
label: 'Open Mix Master', label: 'Open Mix Master',
@@ -149,12 +148,6 @@ export const baseSearchItems: SearchItem[] = [
description: 'Run a new client-specific mix calculation session.', description: 'Run a new client-specific mix calculation session.',
keywords: 'new mix calculator session client batch size product bags print' 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', href: '/reporting',
label: 'Open Reporting', label: 'Open Reporting',
@@ -167,12 +160,6 @@ export const baseSearchItems: SearchItem[] = [
description: 'Review account details and workspace preferences.', description: 'Review account details and workspace preferences.',
keywords: 'settings account preferences profile workspace' 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) { export function matchesRoute(href: string, pathname: string) {
@@ -198,6 +185,10 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
return [...crumbs, { label: 'Mix Calculator' }]; return [...crumbs, { label: 'Mix Calculator' }];
} }
if (pathname.startsWith('/editor')) {
return [...crumbs, { label: 'Editor' }];
}
if (pathname.startsWith('/mixes')) { if (pathname.startsWith('/mixes')) {
return [...crumbs, { label: 'Mix Master' }]; return [...crumbs, { label: 'Mix Master' }];
} }
+33
View File
@@ -208,6 +208,39 @@ export type ProductCostBreakdown = {
inputs?: Record<string, unknown>; 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 = { export type Scenario = {
id: number; id: number;
name: string; name: string;
+61 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('workspace access policy', () => {
const operationsSession = { const operationsSession = {
@@ -21,6 +21,51 @@ describe('workspace access policy', () => {
token: 'token' 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', () => { it('classifies operations users and sends them to mix calculator by default', () => {
expect(getWorkspaceRole(operationsSession)).toBe('operations'); expect(getWorkspaceRole(operationsSession)).toBe('operations');
expect(getDefaultRouteForRole(operationsSession)).toBe('/mix-calculator'); expect(getDefaultRouteForRole(operationsSession)).toBe('/mix-calculator');
@@ -35,4 +80,19 @@ describe('workspace access policy', () => {
expect(getWorkspaceRole(adminSession)).toBe('admin'); expect(getWorkspaceRole(adminSession)).toBe('admin');
expect(canAccessRoute(adminSession, '/')).toBe(true); 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); 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 { export function getWorkspaceRole(session: AppSession | null | undefined): WorkspaceRole {
if (!session) { if (!session) {
return 'unknown'; return 'unknown';
@@ -42,7 +46,7 @@ export function getWorkspaceRole(session: AppSession | null | undefined): Worksp
return 'client'; return 'client';
} }
if (session.role_name === 'Admin') { if (isLeanAdminRole(session)) {
return 'admin'; return 'admin';
} }
@@ -85,6 +89,14 @@ export function canOpenProducts(session: AppSession | null | undefined) {
return canAccessWorkspaceArea(session, 'products', ['view_products', 'edit_products']); 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) { export function canOpenScenarios(session: AppSession | null | undefined) {
return !!session && hasModuleAccess(session, 'scenarios'); 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: '/mixes', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/mixes') },
{ path: '/products', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/products') }, { 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: '/reporting', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/reporting') },
{ path: '/scenarios', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/scenarios') }, { 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('/raw-materials')) return canOpenRawMaterials(session);
if (pathname.startsWith('/mixes')) return canOpenMixMaster(session); if (pathname.startsWith('/mixes')) return canOpenMixMaster(session);
if (pathname.startsWith('/products')) return canOpenProducts(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('/scenarios')) return canOpenScenarios(session);
if (pathname.startsWith('/reporting')) return canOpenReporting(session); if (pathname.startsWith('/reporting')) return canOpenReporting(session);
if (pathname.startsWith('/settings')) return canOpenSettings(session); if (pathname.startsWith('/settings')) return canOpenSettings(session);
@@ -197,6 +211,7 @@ export function canUseWorkspaceSearch(session: AppSession | null | undefined) {
canOpenDashboard(session) || canOpenDashboard(session) ||
canOpenRawMaterials(session) || canOpenRawMaterials(session) ||
canOpenMixMaster(session) || canOpenMixMaster(session) ||
canOpenEditor(session) ||
canOpenMixCalculator(session) || canOpenMixCalculator(session) ||
canOpenProducts(session) || canOpenProducts(session) ||
canOpenScenarios(session) canOpenScenarios(session)
+4 -2
View File
@@ -4,7 +4,7 @@
import { clientSession, sessionHydrated } from '$lib/session'; import { clientSession, sessionHydrated } from '$lib/session';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import type { DashboardSummary } from '$lib/types'; 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 packageInfo from '../../package.json';
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte'; import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
import { tick } from 'svelte'; import { tick } from 'svelte';
@@ -464,7 +464,9 @@
<h2>{greeting.label}, {firstName($clientSession?.name)}</h2> <h2>{greeting.label}, {firstName($clientSession?.name)}</h2>
</div> </div>
<div class="intro-actions"> <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> </div>
</section> </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> </script>
<section class="page-actions"> <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>
<section class="metric-row"> <section class="ui-metric-row module-section">
<article class="metric-card"> <article class="ui-metric-card">
<span>Total Mixes</span> <span>Total Mixes</span>
<strong>{data.mixes.length}</strong> <strong>{data.mixes.length}</strong>
<p>Saved mix definitions</p> <p>Saved mix definitions</p>
</article> </article>
<article class="metric-card"> <article class="ui-metric-card">
<span>Average Cost / Kg</span> <span>Average Cost / Kg</span>
<strong>{currency(averageCost, 4)}</strong> <strong>{currency(averageCost, 4)}</strong>
<p>Across all saved mixes</p> <p>Across all saved mixes</p>
</article> </article>
<article class="metric-card"> <article class="ui-metric-card">
<span>Warnings</span> <span>Warnings</span>
<strong>{warningCount}</strong> <strong>{warningCount}</strong>
<p>Mixes needing review</p> <p>Mixes needing review</p>
</article> </article>
</section> </section>
<section class="table-card"> <section class="ui-panel module-section">
<div class="section-heading"> <div class="ui-section-heading">
<div> <div>
<p class="eyebrow">Table View</p> <p class="ui-eyebrow">Table View</p>
<h3>Saved mixes</h3> <h3>Saved mixes</h3>
</div> </div>
<span class="soft-pill">Open any mix to edit</span> <span class="ui-pill positive">Open any mix to edit</span>
</div> </div>
<div class="table-wrap"> <div class="ui-table-wrap">
<table> <table class="ui-table">
<thead> <thead>
<tr> <tr>
<th>Mix</th> <th>Mix</th>
@@ -155,8 +155,8 @@
{#each data.mixes as mix} {#each data.mixes as mix}
<tr> <tr>
<td data-label="Mix"> <td data-label="Mix">
<div class="table-item"> <div class="ui-table-identity">
<span class="row-badge">MX</span> <span class="ui-row-mark">MX</span>
<div> <div>
<strong>{mix.name}</strong> <strong>{mix.name}</strong>
<span>v{mix.version ?? 1}</span> <span>v{mix.version ?? 1}</span>
@@ -169,14 +169,14 @@
<td data-label="Total Cost">{currency(mix.total_mix_cost)}</td> <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="Cost / Kg">{currency(mix.mix_cost_per_kg, 4)}</td>
<td data-label="Status"> <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>
<td class="menu-cell" data-label="Actions"> <td class="menu-cell" data-label="Actions">
<div class="menu-wrap"> <div class="menu-wrap">
<button <button
aria-expanded={activeMenuId === mix.id} aria-expanded={activeMenuId === mix.id}
aria-haspopup="menu" aria-haspopup="menu"
class="menu-trigger" class="ui-button secondary menu-trigger"
type="button" type="button"
onclick={(event) => toggleMenu(mix.id, event)} onclick={(event) => toggleMenu(mix.id, event)}
> >
@@ -199,218 +199,21 @@
</section> </section>
<style> <style>
h2,
h3, h3,
p { p {
margin: 0; margin: 0;
} }
.eyebrow { .module-section {
color: #7f8e85;
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-actions,
.metric-row,
.table-card {
margin-bottom: 1.12rem; margin-bottom: 1.12rem;
} }
.page-actions { .page-actions {
margin-bottom: 1.12rem;
display: flex; display: flex;
justify-content: flex-end; 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 { .menu-cell {
width: 1%; width: 1%;
} }
@@ -421,16 +224,8 @@
} }
.menu-trigger { .menu-trigger {
display: inline-flex; min-height: 2.25rem;
align-items: center; padding: 0.52rem 0.72rem;
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;
} }
.menu-panel { .menu-panel {
@@ -456,82 +251,7 @@
background: var(--panel-soft); background: var(--panel-soft);
} }
@media (max-width: 980px) {
.metric-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) { @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-wrap,
.menu-trigger { .menu-trigger {
width: 100%; width: 100%;
+42 -42
View File
@@ -264,20 +264,20 @@
<div class="panel-body"> <div class="panel-body">
{#if activeView === 'overview'} {#if activeView === 'overview'}
<section class="metric-row"> <section class="ui-metric-row metric-row">
<article class="metric-card"> <article class="ui-metric-card metric-card">
<span>Total Spend Tracked</span> <span>Total Spend Tracked</span>
<strong>{currency(totalSpend)}</strong> <strong>{currency(totalSpend)}</strong>
<p>Across current market values</p> <p>Across current market values</p>
</article> </article>
<article class="metric-card"> <article class="ui-metric-card metric-card">
<span>Average Waste</span> <span>Average Waste</span>
<strong>{(averageWaste * 100).toFixed(1)}%</strong> <strong>{(averageWaste * 100).toFixed(1)}%</strong>
<p>Current blended input loss</p> <p>Current blended input loss</p>
</article> </article>
<article class="metric-card"> <article class="ui-metric-card metric-card">
<span>Latest Price Update</span> <span>Latest Price Update</span>
<strong>{formatDate(latestEffectiveDate)}</strong> <strong>{formatDate(latestEffectiveDate)}</strong>
<p>Most recent effective date on file</p> <p>Most recent effective date on file</p>
@@ -286,10 +286,10 @@
<section class="top-grid"> <section class="top-grid">
<div class="summary-stack"> <div class="summary-stack">
<article class="surface-card"> <article class="ui-panel surface-card">
<div class="section-heading"> <div class="ui-section-heading section-heading">
<div> <div>
<p class="eyebrow">Downstream Snapshot</p> <p class="ui-eyebrow eyebrow">Downstream Snapshot</p>
<h3>Mixes affected by current inputs</h3> <h3>Mixes affected by current inputs</h3>
</div> </div>
</div> </div>
@@ -318,10 +318,10 @@
{/if} {/if}
</article> </article>
<article class="surface-card"> <article class="ui-panel surface-card">
<div class="section-heading"> <div class="ui-section-heading section-heading">
<div> <div>
<p class="eyebrow">Product Exposure</p> <p class="ui-eyebrow eyebrow">Product Exposure</p>
<h3>Finished outputs linked to live pricing</h3> <h3>Finished outputs linked to live pricing</h3>
</div> </div>
</div> </div>
@@ -354,53 +354,53 @@
{:else if activeView === 'create'} {:else if activeView === 'create'}
<section class="top-grid create-grid"> <section class="top-grid create-grid">
<article class="surface-card form-card"> <article class="ui-panel surface-card form-card">
<div class="section-heading"> <div class="ui-section-heading section-heading">
<div> <div>
<p class="eyebrow">Create Input</p> <p class="ui-eyebrow eyebrow">Create Input</p>
<h3>Add a new raw material</h3> <h3>Add a new raw material</h3>
</div> </div>
<span class="soft-pill">Live costing source</span> <span class="ui-pill positive soft-pill">Live costing source</span>
</div> </div>
<form class="material-form" onsubmit={handleCreateMaterial}> <form class="material-form" onsubmit={handleCreateMaterial}>
<div class="form-grid"> <div class="ui-form-grid form-grid">
<label> <label class="ui-field">
Name Name
<input name="name" required /> <input name="name" required />
</label> </label>
<label> <label class="ui-field">
Supplier Supplier
<input name="supplier" /> <input name="supplier" />
</label> </label>
<label> <label class="ui-field">
Unit of measure Unit of measure
<input name="unit_of_measure" value="tonne" required /> <input name="unit_of_measure" value="tonne" required />
</label> </label>
<label> <label class="ui-field">
Kg per unit Kg per unit
<input name="kg_per_unit" type="number" min="0.0001" step="0.0001" value="1000" required /> <input name="kg_per_unit" type="number" min="0.0001" step="0.0001" value="1000" required />
</label> </label>
<label> <label class="ui-field">
Market value Market value
<input name="market_value" type="number" min="0.0001" step="0.0001" required /> <input name="market_value" type="number" min="0.0001" step="0.0001" required />
</label> </label>
<label> <label class="ui-field">
Waste percentage Waste percentage
<input name="waste_percentage" type="number" min="0" max="1" step="0.0001" value="0" required /> <input name="waste_percentage" type="number" min="0" max="1" step="0.0001" value="0" required />
</label> </label>
<label> <label class="ui-field">
Effective date Effective date
<input name="effective_date" type="date" value={today} required /> <input name="effective_date" type="date" value={today} required />
</label> </label>
<label> <label class="ui-field">
Status Status
<select name="status"> <select name="status">
<option value="active">Active</option> <option value="active">Active</option>
@@ -410,29 +410,29 @@
</label> </label>
</div> </div>
<div class="form-grid single"> <div class="ui-form-grid form-grid single">
<label> <label class="ui-field">
Material notes Material notes
<textarea name="notes" rows="3"></textarea> <textarea name="notes" rows="3"></textarea>
</label> </label>
<label> <label class="ui-field">
Price notes Price notes
<textarea name="price_notes" rows="3"></textarea> <textarea name="price_notes" rows="3"></textarea>
</label> </label>
</div> </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'} {isCreating ? 'Creating material...' : 'Create raw material'}
</button> </button>
</form> </form>
</article> </article>
<div class="summary-stack"> <div class="summary-stack">
<article class="surface-card mini-metric-card"> <article class="ui-panel surface-card mini-metric-card">
<div class="section-heading"> <div class="ui-section-heading section-heading">
<div> <div>
<p class="eyebrow">Portfolio Health</p> <p class="ui-eyebrow eyebrow">Portfolio Health</p>
<h3>Current input coverage</h3> <h3>Current input coverage</h3>
</div> </div>
</div> </div>
@@ -470,7 +470,7 @@
{@const impactedMixes = getImpactedMixes(material.id)} {@const impactedMixes = getImpactedMixes(material.id)}
{@const impactedProducts = getImpactedProducts(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-header">
<div class="material-title"> <div class="material-title">
<span class={`material-icon ${material.status === 'active' ? 'active' : 'muted'}`}>RM</span> <span class={`material-icon ${material.status === 'active' ? 'active' : 'muted'}`}>RM</span>
@@ -480,7 +480,7 @@
</div> </div>
</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>
<div class="material-grid"> <div class="material-grid">
@@ -505,37 +505,37 @@
</article> </article>
</section> </section>
<form class="price-card" onsubmit={(event) => handleAddPrice(event, material.id)}> <form class="ui-panel-soft price-card" onsubmit={(event) => handleAddPrice(event, material.id)}>
<div class="section-heading"> <div class="ui-section-heading section-heading">
<div> <div>
<p class="eyebrow">New Version</p> <p class="ui-eyebrow eyebrow">New Version</p>
<h4>Record a fresh price</h4> <h4>Record a fresh price</h4>
</div> </div>
</div> </div>
<div class="form-grid compact"> <div class="ui-form-grid compact form-grid">
<label> <label class="ui-field">
Market value Market value
<input name="market_value" type="number" min="0.0001" step="0.0001" required /> <input name="market_value" type="number" min="0.0001" step="0.0001" required />
</label> </label>
<label> <label class="ui-field">
Waste percentage Waste percentage
<input name="waste_percentage" type="number" min="0" max="1" step="0.0001" value="0" required /> <input name="waste_percentage" type="number" min="0" max="1" step="0.0001" value="0" required />
</label> </label>
<label> <label class="ui-field">
Effective date Effective date
<input name="effective_date" type="date" value={today} required /> <input name="effective_date" type="date" value={today} required />
</label> </label>
</div> </div>
<label> <label class="ui-field">
Notes Notes
<textarea name="notes" rows="2"></textarea> <textarea name="notes" rows="2"></textarea>
</label> </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'} {pendingMaterialId === material.id ? 'Saving price...' : 'Save price version'}
</button> </button>
</form> </form>
@@ -592,7 +592,7 @@
{/each} {/each}
{#if data.rawMaterials.length > pageSize} {#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> <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"> <div class="pagination-actions">
<button type="button" class="pagination-button" onclick={() => (materialLibraryPage -= 1)} disabled={materialLibraryPage === 1}>Previous</button> <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); const approvedCount = $derived(scenarioRows.filter((scenario) => scenario.status === 'approved').length);
</script> </script>
<section class="metric-row"> <section class="ui-metric-row module-section">
<article class="metric-card"> <article class="ui-metric-card">
<span>Total Scenarios</span> <span>Total Scenarios</span>
<strong>{scenarioRows.length}</strong> <strong>{scenarioRows.length}</strong>
<p>Saved planning workspaces</p> <p>Saved planning workspaces</p>
</article> </article>
<article class="metric-card"> <article class="ui-metric-card">
<span>Approved</span> <span>Approved</span>
<strong>{approvedCount}</strong> <strong>{approvedCount}</strong>
<p>Ready for pricing reference</p> <p>Ready for pricing reference</p>
</article> </article>
<article class="metric-card"> <article class="ui-metric-card">
<span>Overrides In Use</span> <span>Overrides In Use</span>
<strong>{scenarioRows.reduce((sum, row) => sum + row.overrideKeys.length, 0)}</strong> <strong>{scenarioRows.reduce((sum, row) => sum + row.overrideKeys.length, 0)}</strong>
<p>Total override keys across all scenarios</p> <p>Total override keys across all scenarios</p>
</article> </article>
</section> </section>
<section class="scenario-list"> <section class="ui-panel module-section">
{#each scenarioRows as scenario} <div class="ui-section-heading">
<article class="surface-card"> <div>
<div class="scenario-header"> <p class="ui-eyebrow">Scenario Library</p>
<div> <h3>Planning scenarios</h3>
<p class="eyebrow">Scenario</p> </div>
<h3>{scenario.name}</h3> <span class="ui-pill neutral">{scenarioRows.length} saved</span>
<p>{scenario.description ?? 'No description provided yet.'}</p> </div>
</div>
<span class={`status-pill ${scenario.status === 'approved' ? 'positive' : 'neutral'}`}>{scenario.status}</span> <div class="ui-table-wrap">
</div> <table class="ui-table scenario-table">
<thead>
<div class="scenario-grid"> <tr>
<section class="detail-card"> <th>Scenario</th>
<div class="detail-row"> <th>Status</th>
<span>Override count</span> <th>Overrides</th>
<strong>{scenario.overrideKeys.length}</strong> <th>Payload</th>
</div> </tr>
<div class="detail-row"> </thead>
<span>Primary state</span> <tbody>
<strong>{scenario.status}</strong> {#each scenarioRows as scenario}
</div> <tr>
</section> <td data-label="Scenario">
<div class="ui-table-identity scenario-identity">
<section class="detail-card"> <span class="ui-row-mark">SC</span>
<p class="eyebrow">Override Keys</p> <div>
{#if scenario.overrideKeys.length} <strong>{scenario.name}</strong>
<div class="chip-list"> <span>{scenario.description ?? 'No description provided yet.'}</span>
{#each scenario.overrideKeys as key} </div>
<span>{key}</span> </div>
{/each} </td>
</div> <td data-label="Status">
{:else} <span class={`ui-pill ${scenario.status === 'approved' ? 'positive' : 'neutral'}`}>{scenario.status}</span>
<p class="empty">No overrides have been defined yet.</p> </td>
{/if} <td data-label="Overrides">
</section> {#if scenario.overrideKeys.length}
</div> <div class="chip-list">
{#each scenario.overrideKeys as key}
<section class="json-card"> <span>{key}</span>
<div class="json-header"> {/each}
<h4>Scenario payload</h4> </div>
<span>JSON view</span> {:else}
</div> <span class="ui-muted">No overrides</span>
<pre>{JSON.stringify(scenario.overrides, null, 2)}</pre> {/if}
</section> </td>
</article> <td data-label="Payload">
{/each} <details class="payload-details">
<summary>View JSON</summary>
<pre>{JSON.stringify(scenario.overrides, null, 2)}</pre>
</details>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section> </section>
<style> <style>
h2,
h3, h3,
h4,
p, p,
pre { pre {
margin: 0; margin: 0;
} }
.eyebrow { .module-section {
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; 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 { .chip-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.6rem; gap: 0.42rem;
margin-top: 0.7rem; max-width: 28rem;
} }
.chip-list span { .chip-list span {
padding: 0.45rem 0.7rem; padding: 0.36rem 0.62rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: 999px;
background: var(--panel-soft); background: var(--panel-soft);
color: #365044; color: var(--color-text-secondary);
font-size: 0.84rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
} }
.json-header { .scenario-table {
margin-bottom: 0.8rem; min-width: 58rem;
} }
.json-header h4 { .scenario-identity {
font-size: 1rem; min-width: 22rem;
}
.payload-details {
max-width: 24rem;
}
.payload-details summary {
width: fit-content;
color: var(--green-deep);
font-weight: 700; font-weight: 700;
cursor: pointer;
} }
pre { .payload-details pre {
padding: 1rem; max-height: 14rem;
border-radius: 1rem; margin-top: 0.7rem;
background: var(--panel-soft); padding: 0.9rem;
border: 1px solid var(--line);
overflow: auto; overflow: auto;
font-size: 0.85rem; border: 1px solid var(--line);
line-height: 1.55; border-radius: 0.82rem;
} background: var(--panel);
color: var(--muted);
@media (max-width: 960px) { font-size: 0.8rem;
.metric-row, line-height: 1.5;
.scenario-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.scenario-header,
.detail-row,
.json-header {
flex-direction: column;
align-items: flex-start;
}
} }
</style> </style>