v0.1.11 - Editor
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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' }];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user