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
|
./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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
"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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
("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",
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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": {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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' }];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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
@@ -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>
|
</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%;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -11,53 +11,61 @@
|
|||||||
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 class="scenario-header">
|
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Scenario</p>
|
<p class="ui-eyebrow">Scenario Library</p>
|
||||||
<h3>{scenario.name}</h3>
|
<h3>Planning scenarios</h3>
|
||||||
<p>{scenario.description ?? 'No description provided yet.'}</p>
|
</div>
|
||||||
|
<span class="ui-pill neutral">{scenarioRows.length} saved</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class={`status-pill ${scenario.status === 'approved' ? 'positive' : 'neutral'}`}>{scenario.status}</span>
|
<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>
|
||||||
|
|
||||||
<div class="scenario-grid">
|
|
||||||
<section class="detail-card">
|
|
||||||
<div class="detail-row">
|
|
||||||
<span>Override count</span>
|
|
||||||
<strong>{scenario.overrideKeys.length}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
</td>
|
||||||
<span>Primary state</span>
|
<td data-label="Status">
|
||||||
<strong>{scenario.status}</strong>
|
<span class={`ui-pill ${scenario.status === 'approved' ? 'positive' : 'neutral'}`}>{scenario.status}</span>
|
||||||
</div>
|
</td>
|
||||||
</section>
|
<td data-label="Overrides">
|
||||||
|
|
||||||
<section class="detail-card">
|
|
||||||
<p class="eyebrow">Override Keys</p>
|
|
||||||
{#if scenario.overrideKeys.length}
|
{#if scenario.overrideKeys.length}
|
||||||
<div class="chip-list">
|
<div class="chip-list">
|
||||||
{#each scenario.overrideKeys as key}
|
{#each scenario.overrideKeys as key}
|
||||||
@@ -65,216 +73,79 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty">No overrides have been defined yet.</p>
|
<span class="ui-muted">No overrides</span>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</td>
|
||||||
</div>
|
<td data-label="Payload">
|
||||||
|
<details class="payload-details">
|
||||||
<section class="json-card">
|
<summary>View JSON</summary>
|
||||||
<div class="json-header">
|
|
||||||
<h4>Scenario payload</h4>
|
|
||||||
<span>JSON view</span>
|
|
||||||
</div>
|
|
||||||
<pre>{JSON.stringify(scenario.overrides, null, 2)}</pre>
|
<pre>{JSON.stringify(scenario.overrides, null, 2)}</pre>
|
||||||
</section>
|
</details>
|
||||||
</article>
|
</td>
|
||||||
|
</tr>
|
||||||
{/each}
|
{/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>
|
||||||
|
|||||||
Reference in New Issue
Block a user