Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live.
Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows
Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator).
Fix: Security headers on all API responses (hardening)
Add: New mix button available on the Mix Editor.
Add: New ingredient button available on the Ingredient Editor
This commit is contained in:
2026-06-16 14:43:17 +12:00
parent 8f9a7b8193
commit 7db95e2027
46 changed files with 3805 additions and 1049 deletions
+7
View File
@@ -33,5 +33,12 @@ LOGIN_RATE_LIMIT_ATTEMPTS=8
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
DOCS_ENABLED=false
# Read-only Power BI / external data API at /api/v1. Set a long random key to
# enable it; leave blank to disable the API entirely. Power BI sends this as an
# "X-API-Key" header (or "?api_key=" query parameter).
POWERBI_API_KEY=V7BI59yhRBF7VMiPNfgmqPxrsPuNuPFJ
# Tenant the Power BI API reads from. Defaults to CLIENT_TENANT_ID.
POWERBI_TENANT_ID=
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
+5
View File
@@ -1,5 +1,10 @@
## Repository operations
### RUles for Svelte
If a block has its own UI + state + behaviour, make it a component.
If logic is reused or long, move it to a .ts utility file.
If CSS is over 300500 lines, split components.
### Dependencies
Current app dependency entry points:
+111
View File
@@ -13,6 +13,8 @@ from app.schemas.editor import (
EditorIngredientRow,
EditorIngredientUpdate,
EditorMixFormulaRead,
EditorMixCreate,
EditorMixFormulaReplace,
EditorMixIngredientCreate,
EditorMixIngredientUpdate,
EditorMixRow,
@@ -22,9 +24,11 @@ from app.schemas.editor import (
EditorProductIngredientUpdate,
EditorProductRow,
EditorProductUpdate,
EditorResolvedMixFormula,
)
from app.services.client_access_service import has_access_level
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
from app.services.mix_calculator_service import resolve_editor_mix_formula, resolve_representative_product
router = APIRouter(prefix="/api/editor", tags=["editor"])
@@ -250,6 +254,25 @@ def list_editor_mixes(
]
@router.post("/mixes", response_model=EditorMixRow, status_code=201)
def create_editor_mix(
payload: EditorMixCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = Mix(
tenant_id=session.tenant_id or "",
client_name=payload.client_name.strip(),
name=payload.name.strip(),
notes=payload.notes,
)
db.add(mix)
db.commit()
db.refresh(mix)
# A brand-new mix has no products yet, so it reads as Inactive (no visible products).
return _serialize_mix_row(mix, visible_count=0, product_count=0)
@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
def update_editor_mix(
mix_id: int,
@@ -377,6 +400,94 @@ def delete_editor_mix_ingredient(
return _serialize_mix_formula(mix)
@router.get("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
def get_editor_mix_resolved_formula(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
"""The mix formula as the Mix Calculator reads it (product-first resolution).
This is what the Mix Editor displays, so the two surfaces show identical
ingredients and quantities. See `resolve_editor_mix_formula`.
"""
tenant_id = session.tenant_id or ""
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
@router.put("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
def replace_editor_mix_formula(
mix_id: int,
payload: EditorMixFormulaReplace,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
"""Replace a mix's whole formula in one save.
Writes back to the *same source* the Mix Calculator reads: the representative
product's own formula (`ProductIngredient`) when it has one, otherwise the
shared mix master (`MixIngredient`). Either way the calculator immediately
reflects the edit.
"""
tenant_id = session.tenant_id or ""
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
raw_ids = [row.raw_material_id for row in payload.rows]
if len(set(raw_ids)) != len(raw_ids):
raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix")
existing_ids = set(
db.scalars(
select(RawMaterial.id).where(RawMaterial.tenant_id == tenant_id, RawMaterial.id.in_(raw_ids))
).all()
)
missing = [raw_id for raw_id in raw_ids if raw_id not in existing_ids]
if missing:
raise HTTPException(status_code=404, detail="Raw material not found")
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix_id)
if product is not None and product.ingredients:
# Replace the representative product's own formula.
for ingredient in list(product.ingredients):
db.delete(ingredient)
db.flush()
for sort_order, row in enumerate(payload.rows, start=1):
db.add(
ProductIngredient(
tenant_id=tenant_id,
product_id=product.id,
raw_material_id=row.raw_material_id,
quantity_kg=row.quantity_kg,
sort_order=sort_order,
notes=row.notes,
)
)
else:
# No product-specific formula in play: edit the shared mix master, which
# is what the calculator falls back to for this mix.
for ingredient in list(mix.ingredients):
db.delete(ingredient)
db.flush()
for row in payload.rows:
db.add(
MixIngredient(
tenant_id=tenant_id,
mix_id=mix.id,
raw_material_id=row.raw_material_id,
quantity_kg=row.quantity_kg,
notes=row.notes,
)
)
db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
def get_editor_product_ingredients(
product_id: int,
+78
View File
@@ -0,0 +1,78 @@
"""Read-only external data API (`/api/v1`).
A deliberately simple, API-key authenticated surface for Power BI (and any other
external reporting tool). It is intentionally separate from the cookie/JWT
session model used by the operator frontend: external tools cannot hold a
browser session, so they present a single static key instead.
Authentication: send the key either as an ``X-API-Key`` request header or an
``api_key`` query-string parameter (Power BI's Web connector supports both).
The key is configured via the ``POWERBI_API_KEY`` environment variable; when it
is blank the whole API is disabled and every request returns 503.
"""
from __future__ import annotations
import secrets
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security_logging import log_security_event
from app.db.session import get_db
from app.models.throughput import ProductionThroughput
from app.services.throughput_service import serialize_entry
router = APIRouter(prefix="/api/v1", tags=["public-v1"])
_API_KEY_HEADER = "X-API-Key"
def require_powerbi_api_key(request: Request) -> str:
"""Authorize an external request via the static Power BI API key.
Returns the tenant the caller may read. Raises 503 when the API is not
configured, or 401 when the key is missing/incorrect.
"""
configured = settings.powerbi_api_key
if not configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The data API is not configured.",
)
presented = request.headers.get(_API_KEY_HEADER) or request.query_params.get("api_key") or ""
# Constant-time comparison so the endpoint does not leak key length/contents
# through response timing.
if not presented or not secrets.compare_digest(presented, configured):
log_security_event("authz.denied", role="powerbi", reason="invalid_api_key")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key")
return settings.powerbi_tenant_id
@router.get("/throughput")
def list_throughput(
date_from: date | None = Query(default=None, description="Only entries on/after this production date (YYYY-MM-DD)."),
date_to: date | None = Query(default=None, description="Only entries on/before this production date (YYYY-MM-DD)."),
limit: int = Query(default=5000, ge=1, le=50000),
tenant_id: str = Depends(require_powerbi_api_key),
db: Session = Depends(get_db),
):
"""Flat list of production throughput entries for Power BI.
One row per packing run, oldest first so incremental refreshes append
naturally. Each row carries the same fields the operator UI shows
(date, product, bag size, quantity, calculated kg, QA flags, staff, notes).
"""
stmt = select(ProductionThroughput).where(ProductionThroughput.tenant_id == tenant_id)
if date_from is not None:
stmt = stmt.where(ProductionThroughput.production_date >= date_from)
if date_to is not None:
stmt = stmt.where(ProductionThroughput.production_date <= date_to)
stmt = stmt.order_by(ProductionThroughput.production_date.asc(), ProductionThroughput.id.asc()).limit(limit)
return [serialize_entry(entry) for entry in db.scalars(stmt).all()]
+8
View File
@@ -58,6 +58,12 @@ class Settings:
login_rate_limit_window_seconds: int
trusted_hosts: tuple[str, ...]
docs_enabled: bool
# Static API key for the read-only Power BI / external data API (`/api/v1`).
# Blank disables the API entirely (every request returns 503).
powerbi_api_key: str
# Tenant the Power BI API reads from. Defaults to the costing client tenant
# (where internal staff store throughput), so a single key serves Irwin.
powerbi_tenant_id: str
@classmethod
def from_env(cls) -> "Settings":
@@ -98,6 +104,8 @@ class Settings:
login_rate_limit_window_seconds=int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")),
trusted_hosts=_parse_csv_env(os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver")),
docs_enabled=_env_flag("DOCS_ENABLED", default=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() != "production"),
powerbi_api_key=os.getenv("POWERBI_API_KEY", "").strip(),
powerbi_tenant_id=os.getenv("POWERBI_TENANT_ID", os.getenv("CLIENT_TENANT_ID", "hunter-premium-produce")).strip(),
)
settings._validate()
return settings
+8
View File
@@ -30,6 +30,7 @@ from app.api.ordering_admin import router as ordering_admin_router
from app.api.powerbi import router as powerbi_router
from app.api.product_costing import router as product_costing_router
from app.api.products import router as products_router
from app.api.public_v1 import router as public_v1_router
from app.api.raw_materials import router as raw_materials_router
from app.api.scenarios import router as scenarios_router
from app.api.throughput import router as throughput_router
@@ -209,6 +210,7 @@ app.include_router(throughput_router)
app.include_router(ordering_router)
app.include_router(ordering_admin_router)
app.include_router(powerbi_router)
app.include_router(public_v1_router)
@app.middleware("http")
@@ -270,6 +272,11 @@ async def enforce_request_limits_and_csrf(request: Request, call_next):
"script-src 'self'; "
"font-src 'self' data:; "
"connect-src 'self'; "
# PDF previews/printing load a same-origin blob: URL into an iframe.
# Without an explicit frame-src/child-src these fall back to default-src
# ('self'), which blocks blob: and breaks the in-app print dialog.
"frame-src 'self' blob:; "
"child-src 'self' blob:; "
"frame-ancestors 'self'; "
"base-uri 'self'; "
"form-action 'self'"
@@ -315,6 +322,7 @@ def root():
"scenarios": "/api/scenarios",
"operations_throughput": "/api/throughput",
"client_access": "/api/client-access",
"powerbi_throughput": "/api/v1/throughput",
"docs": "/docs",
},
}
+57
View File
@@ -32,6 +32,14 @@ class EditorProductUpdate(BaseModel):
notes: str | None = Field(default=None, max_length=2000)
class EditorMixCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
client_name: str = Field(min_length=1, max_length=255)
name: str = Field(min_length=1, max_length=255)
notes: str | None = Field(default=None, max_length=2000)
class EditorMixUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -71,6 +79,55 @@ class EditorMixFormulaRead(BaseModel):
total_kg: float
class EditorResolvedMixIngredient(BaseModel):
raw_material_id: int
raw_material_name: str
quantity_kg: float
# This row's share of the mix total, matching the Mix Calculator.
mix_percentage: float
unit: str
notes: str | None
class EditorResolvedMixFormula(BaseModel):
"""A mix formula resolved the way the Mix Calculator reads it.
`source` is `product` when the numbers come from a representative product's
own formula, or `mix` when they come from the shared mix master fallback.
The PUT endpoint writes back to whichever source produced these rows.
"""
id: int
tenant_id: str
client_name: str
name: str
source: str
product_id: int | None
ingredients: list[EditorResolvedMixIngredient]
total_kg: float
class EditorMixFormulaRowInput(BaseModel):
model_config = ConfigDict(extra="forbid")
raw_material_id: int
quantity_kg: float = Field(gt=0)
notes: str | None = Field(default=None, max_length=1000)
class EditorMixFormulaReplace(BaseModel):
"""Full replacement of a mix's formula in one save.
The frontend keeps kilograms as the canonical value (percentages are an
entry aid that resolve back to kg against the total), so the API only needs
the resolved kg per row.
"""
model_config = ConfigDict(extra="forbid")
rows: list[EditorMixFormulaRowInput] = Field(min_length=1)
class EditorMixIngredientCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -93,6 +93,96 @@ def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int]
)
def resolve_representative_product(db: Session, *, tenant_id: str, mix_id: int) -> Product | None:
"""The single product the Mix Calculator surfaces for a given mix.
The calculator lists one representative product per (client, mix) and reads
its formula. The Mix Editor reuses this so it edits exactly what the
calculator shows. Preference order mirrors `build_mix_calculator_options`:
visible products that already have a product-specific formula, ranked by
`_mix_calculator_option_rank`; then any visible product; then any product.
"""
products = db.scalars(
select(Product)
.where(Product.tenant_id == tenant_id, Product.mix_id == mix_id)
.options(
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
)
).all()
if not products:
return None
with_formula = [product for product in products if product.visible and product.ingredients]
pool = with_formula or [product for product in products if product.visible] or list(products)
return min(pool, key=_mix_calculator_option_rank)
def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict:
"""Resolve a mix's formula the way the calculator does, for the editor.
Returns the resolved ingredient rows (with each row's share of the total as
`mix_percentage`), the total kg, and where the formula lives:
`source='product'` (a representative product's own formula) or `source='mix'`
(the shared mix master fallback). `product_id` names the product that owns the
formula when `source='product'`. The save path writes back to that same source.
"""
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix.id)
if product is not None and product.ingredients:
rows, total_kg = _resolved_formula_rows(product)
# Carry each ingredient's note through so saving doesn't wipe it.
notes_by_raw_material = {
ingredient.raw_material_id: ingredient.notes for ingredient in product.ingredients
}
for row in rows:
row["notes"] = notes_by_raw_material.get(row["raw_material_id"])
source = "product"
product_id = product.id
else:
# No product-specific formula: the calculator reads the shared mix master,
# so the editor shows and edits that.
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name
if ingredient.raw_material is not None
else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
"sort_order": index,
"notes": ingredient.notes,
}
for index, ingredient in enumerate(
sorted(mix.ingredients, key=lambda item: item.raw_material.name if item.raw_material else ""),
start=1,
)
]
total_kg = round(sum(row["quantity_kg"] for row in rows), 4)
source = "mix"
product_id = product.id if product is not None else None
ingredients = [
{
"raw_material_id": row["raw_material_id"],
"raw_material_name": row["raw_material_name"],
"quantity_kg": round(row["quantity_kg"], 4),
"mix_percentage": round((row["quantity_kg"] / total_kg) * 100, 4) if total_kg > 0 else 0.0,
"unit": row["unit"],
"notes": row.get("notes"),
}
for row in rows
]
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"source": source,
"product_id": product_id,
"ingredients": ingredients,
"total_kg": total_kg,
}
def calculate_mix_calculator_preview(
db: Session,
*,
+102
View File
@@ -0,0 +1,102 @@
"""The Mix Editor must resolve and edit the SAME formula the Mix Calculator reads.
These cover `resolve_editor_mix_formula` / `resolve_representative_product`: a mix
whose product carries its own formula resolves to that product (not the shared
mix master), percentages are computed against the total, and the representative
product is chosen the way the calculator chooses it.
"""
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.db.session import Base
from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial
from app.services.mix_calculator_service import (
resolve_editor_mix_formula,
resolve_representative_product,
)
TENANT = "hunter-premium-produce"
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _raw(db: Session, name: str) -> RawMaterial:
material = RawMaterial(tenant_id=TENANT, name=name, unit_of_measure="kg", kg_per_unit=1, status="active")
db.add(material)
db.flush()
return material
def test_resolves_product_formula_not_mix_master():
db = _session()
bayley = _raw(db, "Bayley")
filler = _raw(db, "Filler")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Pigeon Mix")
db.add(mix)
db.flush()
# Shared mix master says something different from the product formula.
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=bayley.id, quantity_kg=100))
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=filler.id, quantity_kg=100))
product = Product(tenant_id=TENANT, client_name="Hunter", name="Pigeon 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
db.add(product)
db.flush()
# The calculator's real numbers live here: 787.5 / 1320.41 ~ 59.6%.
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=787.5, sort_order=1))
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=532.91, sort_order=2))
db.commit()
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
assert formula["source"] == "product"
assert formula["product_id"] == product.id
assert formula["total_kg"] == 1320.41
by_name = {row["raw_material_name"]: row for row in formula["ingredients"]}
assert by_name["Bayley"]["quantity_kg"] == 787.5
# Percentage matches the worked example (787.5 / 1320.41 * 100).
assert abs(by_name["Bayley"]["mix_percentage"] - 59.6406) < 0.001
def test_falls_back_to_mix_master_when_no_product_formula():
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
db.add(mix)
db.flush()
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
db.commit()
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
assert formula["source"] == "mix"
assert formula["total_kg"] == 50
assert formula["ingredients"][0]["mix_percentage"] == 100.0
def test_representative_product_prefers_20kg_bag():
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Dual Mix")
db.add(mix)
db.flush()
bulka = Product(tenant_id=TENANT, client_name="Hunter", name="Dual Bulka", mix_id=mix.id, unit_of_measure="500kg bulka", visible=True)
bag = Product(tenant_id=TENANT, client_name="Hunter", name="Dual 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
db.add_all([bulka, bag])
db.flush()
for product in (bulka, bag):
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=maize.id, quantity_kg=20, sort_order=1))
db.commit()
representative = resolve_representative_product(db, tenant_id=TENANT, mix_id=mix.id)
assert representative is not None
assert representative.unit_of_measure == "20kg bag"
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the read-only external data API (`/api/v1`) used by Power BI.
Drives the real FastAPI app via TestClient against an in-memory database, and
swaps the configured API key in by replacing the module-level `settings` object
(the real one is a frozen dataclass).
"""
from __future__ import annotations
from dataclasses import replace
from datetime import date
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api import public_v1
from app.core.config import settings
from app.db.session import Base, get_db
from app.main import app
from app.models.throughput import ProductionThroughput
API_KEY = "test-powerbi-key"
TENANT = "hunter-premium-produce"
def _seed_entry(db, *, tenant_id: str, product: str, quantity: float, when: date) -> None:
db.add(
ProductionThroughput(
tenant_id=tenant_id,
production_date=when,
product_name_snapshot=product,
bag_size=20,
quantity=quantity,
quantity_type="bags",
calculated_kg=quantity * 20,
created_by="test",
)
)
@pytest.fixture()
def db_factory():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def override_get_db():
db = TestingSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
yield TestingSession
app.dependency_overrides.clear()
def _make_client(monkeypatch, *, api_key: str) -> TestClient:
# Replace the whole settings object the router reads (frozen dataclass).
monkeypatch.setattr(public_v1, "settings", replace(settings, powerbi_api_key=api_key, powerbi_tenant_id=TENANT))
return TestClient(app)
def test_disabled_when_key_unset(monkeypatch, db_factory):
client = _make_client(monkeypatch, api_key="")
response = client.get("/api/v1/throughput", headers={"X-API-Key": "anything"})
assert response.status_code == 503
def test_rejects_missing_and_wrong_key(monkeypatch, db_factory):
client = _make_client(monkeypatch, api_key=API_KEY)
assert client.get("/api/v1/throughput").status_code == 401
assert client.get("/api/v1/throughput", headers={"X-API-Key": "nope"}).status_code == 401
def test_returns_entries_for_tenant(monkeypatch, db_factory):
db = db_factory()
_seed_entry(db, tenant_id=TENANT, product="Maize", quantity=10, when=date(2026, 1, 5))
_seed_entry(db, tenant_id=TENANT, product="Barley", quantity=5, when=date(2026, 1, 6))
# An entry in another tenant must never leak through.
_seed_entry(db, tenant_id="someone-else", product="Secret", quantity=99, when=date(2026, 1, 7))
db.commit()
db.close()
client = _make_client(monkeypatch, api_key=API_KEY)
# Header auth.
response = client.get("/api/v1/throughput", headers={"X-API-Key": API_KEY})
assert response.status_code == 200
rows = response.json()
names = [row["product_name_snapshot"] for row in rows]
assert names == ["Maize", "Barley"] # oldest first, other tenant excluded
assert rows[0]["calculated_kg"] == 200.0
# Query-string auth works too (Power BI Web connector convenience).
assert client.get(f"/api/v1/throughput?api_key={API_KEY}").status_code == 200
def test_date_filter(monkeypatch, db_factory):
db = db_factory()
_seed_entry(db, tenant_id=TENANT, product="Old", quantity=1, when=date(2026, 1, 1))
_seed_entry(db, tenant_id=TENANT, product="New", quantity=1, when=date(2026, 2, 1))
db.commit()
db.close()
client = _make_client(monkeypatch, api_key=API_KEY)
response = client.get("/api/v1/throughput?date_from=2026-01-15", headers={"X-API-Key": API_KEY})
assert [row["product_name_snapshot"] for row in response.json()] == ["New"]
+108
View File
@@ -0,0 +1,108 @@
"""Regression guards for the print/PDF Content-Security-Policy.
The in-app print dialog loads a generated PDF as a same-origin ``blob:`` URL into
an iframe and calls ``contentWindow.print()``. If the CSP omits ``frame-src`` /
``child-src`` for ``blob:`` the directive falls back to ``default-src 'self'``,
which silently blocks the frame and breaks printing for every user (regardless of
role). These tests pin the policy on both layers that emit it:
* the FastAPI security middleware (covers every API response), and
* the production nginx config (the source of the *document* CSP that actually
governs ``frame-src`` in the browser).
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.core.config import settings
from app.core.security import issue_token
from app.main import app
REPO_ROOT = Path(__file__).resolve().parents[2]
NGINX_CONFIGS = [REPO_ROOT / "deploy" / "nginx" / "clients.lean-101.conf"]
# Directives the print flow depends on. blob: must be framable, and that must not
# come at the cost of dropping the same-origin baseline.
REQUIRED_FRAME_SOURCES = {"'self'", "blob:"}
def _parse_csp(header: str) -> dict[str, set[str]]:
"""Parse a CSP header string into ``{directive: {sources}}``."""
directives: dict[str, set[str]] = {}
for part in header.split(";"):
tokens = part.split()
if not tokens:
continue
directives[tokens[0].lower()] = set(tokens[1:])
return directives
def _assert_blob_framing(header: str) -> None:
csp = _parse_csp(header)
# frame-src must exist and allow self + blob (no falling back to default-src).
assert "frame-src" in csp, f"frame-src missing from CSP: {header!r}"
assert REQUIRED_FRAME_SOURCES <= csp["frame-src"], (
f"frame-src must allow {REQUIRED_FRAME_SOURCES}, got {csp['frame-src']}"
)
# child-src is the Safari fallback for frame-src; keep it aligned.
assert "child-src" in csp, f"child-src missing from CSP: {header!r}"
assert "blob:" in csp["child-src"], f"child-src must allow blob:, got {csp['child-src']}"
# We only widened framing: the same-origin default must stay intact.
assert csp.get("default-src") == {"'self'"}, f"default-src weakened: {csp.get('default-src')}"
# --- Backend middleware policy ------------------------------------------------
@pytest.fixture()
def client() -> TestClient:
with TestClient(app) as test_client:
yield test_client
def test_backend_csp_allows_blob_frames(client: TestClient) -> None:
response = client.get("/health")
assert "content-security-policy" in response.headers
_assert_blob_framing(response.headers["content-security-policy"])
def test_backend_csp_present_for_all_users(client: TestClient) -> None:
"""The policy is identical for anonymous, authenticated, and rejected (401)
requests, so printing can never depend on who is signed in."""
admin_token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
responses = [
client.get("/health"), # anonymous
client.get("/api/access/me"), # the endpoint that 401s for warehouse users
client.get("/api/access/me", headers={"Authorization": f"Bearer {admin_token}"}),
]
policies = set()
for response in responses:
header = response.headers.get("content-security-policy")
assert header is not None, f"CSP missing on {response.request.url} ({response.status_code})"
_assert_blob_framing(header)
policies.add(header)
assert len(policies) == 1, "CSP must not vary by authentication state"
# --- Production document policy (nginx) ---------------------------------------
def test_nginx_csp_allows_blob_frames() -> None:
"""Every CSP the production nginx emits must allow blob framing. This guards
the *document* policy, which is what the browser enforces for the print iframe."""
csp_line = re.compile(r'Content-Security-Policy\s+"([^"]+)"', re.IGNORECASE)
for config_path in NGINX_CONFIGS:
assert config_path.exists(), f"missing nginx config: {config_path}"
text = config_path.read_text(encoding="utf-8")
policies = csp_line.findall(text)
assert policies, f"no Content-Security-Policy header found in {config_path}"
for policy in policies:
_assert_blob_framing(policy)
+15
View File
@@ -458,6 +458,21 @@ fi
throw
}
# ── Reload nginx config ───────────────────────────────────────────────────
# The nginx config is a bind-mounted file. `docker compose up` only recreates
# a service when its definition changes, not when a mounted file's contents
# change, so a running nginx keeps serving the config it loaded at start. Force
# a reload so edits to clients.lean-101.conf (routing, security headers/CSP)
# actually take effect on every deploy. Non-fatal: stacks without an nginx
# service simply skip this.
Write-Step "Reloading nginx to apply config changes"
$nginxReload = "cd '$RemotePath' && docker compose $ComposeArgs exec -T nginx nginx -t && docker compose $ComposeArgs exec -T nginx nginx -s reload"
if ((Try-Ssh $nginxReload) -eq 0) {
Write-Ok "nginx reloaded"
} else {
Write-Warn "Skipped nginx reload (no nginx service, or config test failed)"
}
# ── Health check ────────────────────────────────────────────────────────────
Write-Step "Waiting for backend health check ($BackendContainer)"
$healthScript = @"
+11 -1
View File
@@ -27,7 +27,9 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
# frame-src/child-src allow same-origin blob: URLs so the in-app PDF print
# dialog (an iframe pointed at a blob:) is not blocked by the default-src fallback.
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
location /_app/immutable/ {
expires 1y;
@@ -90,6 +92,14 @@ server {
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
# nginx drops inherited add_header directives once a location defines its own,
# so the security headers (incl. the blob:-aware CSP) are repeated here to
# guarantee the HTML document carries them.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
expires -1;
proxy_pass http://lean101_clients_frontend;
proxy_http_version 1.1;
+3
View File
@@ -45,6 +45,9 @@ services:
LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8}
LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300}
DOCS_ENABLED: ${DOCS_ENABLED:-false}
# Read-only Power BI data API (/api/v1). Blank disables it.
POWERBI_API_KEY: ${POWERBI_API_KEY:-}
POWERBI_TENANT_ID: ${POWERBI_TENANT_ID:-${CLIENT_TENANT_ID:-hunter-premium-produce}}
depends_on:
db:
condition: service_healthy
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hunter-app",
"version": "0.1.23",
"version": "0.1.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hunter-app",
"version": "0.1.23",
"version": "0.1.26",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hunter-app",
"version": "0.1.23",
"version": "0.1.26",
"private": true,
"type": "module",
"scripts": {
+17
View File
@@ -8,9 +8,12 @@ import type {
ClientUserModulePermission,
ClientUserUpdateInput,
LoginResponse,
EditorMixCreateInput,
EditorMixUpdateInput,
EditorMixRow,
EditorMixFormula,
EditorResolvedMixFormula,
EditorMixFormulaRowInput,
EditorIngredientRow,
EditorIngredientCreateInput,
EditorIngredientUpdateInput,
@@ -387,6 +390,11 @@ export const api = {
const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes';
return cachedFetchJson<EditorMixRow[]>(path, 'client', fetcher);
},
createEditorMix: (payload: EditorMixCreateInput) =>
request<EditorMixRow>('/api/editor/mixes', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
method: 'PATCH',
@@ -394,6 +402,15 @@ export const api = {
}, 'client'),
editorMixFormula: (mixId: number) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
// The resolved formula matching the Mix Calculator (product-first), used by
// the Mix Editor ingredient panel.
editorMixResolvedFormula: (mixId: number) =>
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {}, 'client'),
replaceEditorMixFormula: (mixId: number, rows: EditorMixFormulaRowInput[]) =>
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {
method: 'PUT',
body: JSON.stringify({ rows })
}, 'client'),
addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {
method: 'POST',
+11 -2
View File
@@ -73,5 +73,14 @@ export function changelogFor(version: string): ChangelogEntry | undefined {
return changelog.find((entry) => entry.version === version);
}
/** The entry for the version the app is currently running, if documented. */
export const currentChangelog: ChangelogEntry | undefined = changelogFor(APP_VERSION);
/** The most recent changelog entry, regardless of the running version. */
export const latestChangelog: ChangelogEntry | undefined = changelog[0];
/**
* The entry the "What's new" dialog shows. Prefer an exact match for the running
* version, but fall back to the latest documented entry so the manual button
* always opens something even when the current build's version (e.g. a hotfix
* suffix like `0.1.24b`) isn't itself listed in the changelog.
*/
export const currentChangelog: ChangelogEntry | undefined =
changelogFor(APP_VERSION) ?? latestChangelog;
+133 -58
View File
@@ -2,13 +2,13 @@
import { api } from '$lib/api';
import AuthGate from '$lib/components/AuthGate.svelte';
import WorkspaceBootCard from '$lib/components/app-shell/WorkspaceBootCard.svelte';
import WorkspaceQuickAccess from '$lib/components/app-shell/WorkspaceQuickAccess.svelte';
import WorkspaceSearchPalette from '$lib/components/app-shell/WorkspaceSearchPalette.svelte';
import WorkspaceSignedOutCard from '$lib/components/app-shell/WorkspaceSignedOutCard.svelte';
import WorkspaceTabletNav from '$lib/components/app-shell/WorkspaceTabletNav.svelte';
import WorkspaceAppsFab, { type WorkspaceFabItem } from '$lib/components/WorkspaceAppsFab.svelte';
import { PALETTE_RESULT_LIMIT, buildSessionKey, filterSearchItems } from '$lib/components/app-shell/utils';
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
import WorkspacePageHeader from '$lib/components/navigation/WorkspacePageHeader.svelte';
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
import { currentChangelog } from '$lib/changelog';
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
@@ -20,6 +20,7 @@
import {
canCreateMixSession as sessionCanCreateMixSession,
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenClientAccess as sessionCanOpenClientAccess,
canOpenDashboard as sessionCanOpenDashboard,
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
@@ -36,9 +37,9 @@
isWorkspaceRouteAllowed
} from '$lib/workspace-access';
import {
accessControlItem,
baseSearchItems,
buildClientNavEntries,
clientBreadcrumbs,
dashboardItem,
editorItem,
ingredientsEditorItem,
@@ -47,7 +48,7 @@
orderingItem,
orderingManageChildren,
orderingManageGroup,
pageTitle,
pageMeta,
productCostingItem,
reportingItem,
throughputItem,
@@ -63,9 +64,11 @@
let { children } = $props();
const isRootRoute = $derived(page.url.pathname === '/');
let paletteOpen = $state(false);
let paletteQuery = $state('');
let quickMenuOpen = $state(false);
let searchOpen = $state(false);
let searchQuery = $state('');
let searchFocusRequest = $state(0);
let appsFabOpen = $state(false);
let sidebarOpen = $state(true);
let userMenuOpen = $state(false);
let navOpen = $state(false);
let showBottomNav = $state(false);
@@ -78,6 +81,8 @@
let seededSearchItems = $state<SearchItem[]>([]);
let seededSearchKey = $state<string | null>(null);
let bootDelayDone = $state(false);
let sidebarStateReady = $state(false);
const SIDEBAR_STORAGE_KEY = 'hsf:shell:sidebar-open';
const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear();
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
@@ -92,9 +97,10 @@
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
const shellTitle = $derived(routeGuardPending ? 'Loading Workspace' : pageTitle(page.url.pathname));
const shellBreadcrumbs = $derived(
routeGuardPending ? clientBreadcrumbs(workspaceHomeHref, $clientSession) : clientBreadcrumbs(page.url.pathname, $clientSession)
const shellPageMeta = $derived(
routeGuardPending
? { title: 'Loading Workspace', category: 'Workspace', icon: dashboardItem.icon }
: pageMeta(page.url.pathname)
);
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
const visibleWorkingDocumentItems = $derived(
@@ -126,9 +132,11 @@
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the
// standalone operations/insights modules. Built from the same access-filtered
// items, so a role only ever sees the families it may open.
const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (costing
// tools plus throughput), then the standalone ordering/insights modules. Built
// from the same access-filtered items, so a role only ever sees the families it
// may open.
const navEntries = $derived(
buildClientNavEntries({
dashboard: visibleDashboardItem,
@@ -149,6 +157,37 @@
const visibleFooterLinks = $derived([
...(!isOperationsUser ? footerLinks : [])
] as FooterLink[]);
const fabItems = $derived.by(() => {
const items = [
visibleDashboardItem,
...visibleWorkingDocumentItems,
visibleMixCalculatorItem,
visibleProductCostingItem,
visibleThroughputItem,
visibleOrderingEntry?.kind === 'item'
? visibleOrderingEntry.item
: visibleOrderingEntry?.group
? {
href: visibleOrderingEntry.group.href ?? '/ordering/manage',
label: visibleOrderingEntry.group.label,
icon: visibleOrderingEntry.group.icon
}
: null,
visibleReportingItem,
visibleEditorItem,
visibleIngredientsEditorItem,
visibleAccessControlItem
].filter((item): item is { href: string; label: string; icon: WorkspaceFabItem['icon'] } => Boolean(item));
const seen = new Set<string>();
return items.flatMap((item) => {
if (seen.has(item.href)) {
return [];
}
seen.add(item.href);
return [{ href: item.href, label: item.label, icon: item.icon } satisfies WorkspaceFabItem];
});
});
const primaryBottomNavigation = $derived(
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
@@ -176,11 +215,37 @@
);
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone));
const showDesktopSidebar = $derived(!showBottomNav);
function openPalette(query = '') {
paletteQuery = query;
paletteOpen = true;
quickMenuOpen = false;
function restoreSidebarState() {
if (typeof window === 'undefined') {
return true;
}
try {
return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) !== 'false';
} catch {
return true;
}
}
function persistSidebarState() {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarOpen));
} catch {
// Storage failures should not block shell interactions.
}
}
function openSearch(query = '') {
searchQuery = query;
searchOpen = true;
searchFocusRequest += 1;
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
}
@@ -194,13 +259,13 @@
}
async function runSearchItem(item: SearchItem) {
paletteOpen = false;
paletteQuery = '';
searchOpen = false;
searchQuery = '';
await goto(item.href);
}
async function openSettings() {
quickMenuOpen = false;
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
await goto('/settings');
@@ -220,17 +285,26 @@
}
}
const paletteState = $derived(filterSearchItems(searchItems, paletteQuery, PALETTE_RESULT_LIMIT));
const searchState = $derived(filterSearchItems(searchItems, searchQuery, PALETTE_RESULT_LIMIT));
$effect(() => {
page.url.pathname;
quickMenuOpen = false;
appsFabOpen = false;
userMenuOpen = false;
paletteOpen = false;
paletteQuery = '';
searchOpen = false;
searchQuery = '';
navOpen = false;
});
$effect(() => {
if (!sidebarStateReady) {
return;
}
sidebarOpen;
persistSidebarState();
});
$effect(() => {
const hydrated = $sessionHydrated;
const sessionKey = buildSessionKey($clientSession);
@@ -271,14 +345,14 @@
});
});
// Search palette items are seeded lazily — three list endpoints worth of
// data only when the user actually opens the palette, not on every login or
// Search items are seeded lazily — three list endpoints worth of
// data only when the user actually opens the search, not on every login or
// navigation. Subsequent opens hit the api.ts cache.
$effect(() => {
const hydrated = $sessionHydrated;
const session = $clientSession;
const sessionKey = buildSessionKey(session);
const shouldSeed = paletteOpen;
const shouldSeed = searchOpen;
if (!hydrated || !session || !sessionKey) {
seededSearchItems = [];
@@ -374,6 +448,9 @@
}
onMount(() => {
sidebarOpen = restoreSidebarState();
sidebarStateReady = true;
const bootTimer = window.setTimeout(() => {
bootDelayDone = true;
}, 1500);
@@ -390,12 +467,12 @@
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
event.preventDefault();
openPalette();
openSearch();
}
if (event.key === 'Escape') {
paletteOpen = false;
quickMenuOpen = false;
searchOpen = false;
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
}
@@ -422,7 +499,7 @@
</script>
<svelte:head>
<title>{shellTitle} | Hunter Premium Produce</title>
<title>{shellPageMeta.title} | Hunter Premium Produce</title>
</svelte:head>
{#if !$clientSession}
@@ -436,9 +513,10 @@
{/if}
</div>
{:else}
<div class="app-shell">
{#if !showBottomNav}
<div class:sidebar-collapsed={!sidebarOpen && showDesktopSidebar} class="app-shell">
{#if showDesktopSidebar}
<ClientPrimaryRail
collapsed={!sidebarOpen}
currentPath={shellPathname}
entries={navEntries}
brandHref={workspaceHomeHref}
@@ -453,18 +531,24 @@
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
<ClientTopbar
breadcrumbs={shellBreadcrumbs}
title={shellTitle}
sessionHydrated={$sessionHydrated}
session={$clientSession}
showSidebarToggle={!showBottomNav}
{sidebarOpen}
{userInitials}
{userMenuOpen}
{canUseWorkspaceSearch}
bind:searchQuery={searchQuery}
bind:searchOpen={searchOpen}
searchFocusRequest={searchFocusRequest}
filteredSearchItems={searchState.filteredItems}
hiddenResultCount={searchState.hiddenResultCount}
{canOpenSettings}
onOpenPalette={() => canUseWorkspaceSearch && openPalette()}
onRunSearchItem={runSearchItem}
onToggleSidebar={() => (sidebarOpen = !sidebarOpen)}
onToggleUserMenu={() => {
userMenuOpen = !userMenuOpen;
quickMenuOpen = false;
appsFabOpen = false;
}}
onOpenSettings={openSettings}
onSignOut={signOut}
@@ -472,6 +556,13 @@
/>
<main class="content">
{#if !routeGuardPending}
<WorkspacePageHeader
category={shellPageMeta.category}
title={shellPageMeta.title}
icon={shellPageMeta.icon}
/>
{/if}
<AuthGate
blocked={routeGuardPending}
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
@@ -487,15 +578,7 @@
</main>
</div>
<WorkspaceQuickAccess
bind:quickMenuOpen
{canOpenMixMaster}
{canCreateMixWorksheet}
{canOpenMixCalculator}
{canCreateMixSession}
{canUseWorkspaceSearch}
onOpenPalette={() => openPalette('')}
/>
<WorkspaceAppsFab bind:open={appsFabOpen} items={fabItems} />
</div>
<WorkspaceTabletNav
@@ -518,9 +601,7 @@
{canCreateMixWorksheet}
{canCreateMixSession}
{canOpenSettings}
{canUseWorkspaceSearch}
pagePath={page.url.pathname}
onOpenPalette={() => openPalette('')}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
@@ -531,16 +612,6 @@
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
{/if}
{#if $clientSession && paletteOpen}
<WorkspaceSearchPalette
bind:query={paletteQuery}
filteredSearchItems={paletteState.filteredItems}
hiddenResultCount={paletteState.hiddenResultCount}
onClose={() => (paletteOpen = false)}
onRunSearchItem={runSearchItem}
/>
{/if}
<style>
.app-shell {
display: grid;
@@ -549,6 +620,10 @@
background: var(--color-bg-app);
}
.app-shell.sidebar-collapsed {
grid-template-columns: 4.5rem minmax(0, 1fr);
}
.signed-out-shell {
min-height: 100vh;
padding: 1.5rem;
@@ -206,9 +206,7 @@
<section class="page-intro">
<div>
<p class="eyebrow">Client Access Control</p>
<h2>Manage module permissions, feature flags, and audit history from one workspace.</h2>
<p>Lean 101 admins and tenant superadmins use the same control surface, and every change lands in the audit log immediately.</p>
<p class="page-intro-copy">Lean 101 admins and tenant superadmins use the same control surface, and every change lands in the audit log immediately.</p>
</div>
<span class="status-pill positive">Signed in as {accessManagerLabel}</span>
</section>
@@ -587,7 +585,6 @@
{/if}
<style>
h2,
h3,
h4,
p,
@@ -617,14 +614,12 @@
gap: 1rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 22ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
.page-intro-copy {
max-width: 62ch;
line-height: 1.55;
}
.page-intro p:last-child,
.page-intro-copy,
.metric-card p,
.card-toolbar p,
.client-row span,
@@ -0,0 +1,164 @@
<script lang="ts">
import { LayoutGrid } from 'lucide-svelte';
import { onMount } from 'svelte';
import { tooltip } from '$lib/actions/tooltip';
import type { ComponentType } from 'svelte';
export type WorkspaceFabItem = {
href: string;
label: string;
icon: ComponentType;
};
let {
items,
open = $bindable(false)
}: {
items: WorkspaceFabItem[];
open?: boolean;
} = $props();
let root = $state<HTMLElement | null>(null);
function close() {
open = false;
}
function toggle() {
open = !open;
}
onMount(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!open || !root || root.contains(event.target as Node)) {
return;
}
close();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
};
});
</script>
{#if items.length}
<div class="apps-fab-wrap" bind:this={root}>
{#if open}
<div class="apps-panel" role="menu" aria-label="Quick access apps">
{#each items as item (item.href)}
<a href={item.href} class="apps-link" role="menuitem" onclick={close}>
<span class="apps-link-icon" aria-hidden="true">
<item.icon size={16} strokeWidth={2} />
</span>
<span>{item.label}</span>
</a>
{/each}
</div>
{/if}
<button
type="button"
class="apps-fab"
aria-expanded={open}
aria-label="Open app launcher"
use:tooltip={{ label: 'Quickly access apps', placement: 'top' }}
onclick={toggle}
>
<LayoutGrid size={20} strokeWidth={2.2} />
</button>
</div>
{/if}
<style>
.apps-fab-wrap {
position: fixed;
right: max(1rem, env(safe-area-inset-right));
bottom: max(1rem, env(safe-area-inset-bottom));
z-index: 46;
display: grid;
justify-items: end;
gap: 0.7rem;
}
.apps-fab {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border: 0;
border-radius: 999px;
background: var(--color-brand);
color: var(--color-on-brand);
box-shadow: 0 20px 36px -22px color-mix(in srgb, var(--color-brand) 75%, transparent);
cursor: pointer;
transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease;
}
.apps-fab:hover {
transform: translateY(-1px);
background: color-mix(in srgb, var(--color-brand) 92%, black);
box-shadow: 0 24px 44px -24px color-mix(in srgb, var(--color-brand) 78%, transparent);
}
.apps-fab:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-brand) 24%, white);
outline-offset: 2px;
}
.apps-panel {
min-width: 15rem;
display: grid;
gap: 0.24rem;
padding: 0.45rem;
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-bg-elevated);
box-shadow: 0 24px 48px -28px rgba(15, 23, 42, 0.32);
}
.apps-link {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.8rem 0.82rem;
border-radius: 0.82rem;
color: var(--color-text-primary);
text-decoration: none;
font-weight: 600;
}
.apps-link:hover {
background: var(--panel-soft);
}
.apps-link-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 0.65rem;
background: color-mix(in srgb, var(--color-brand) 10%, var(--color-bg-surface));
color: var(--color-brand);
flex-shrink: 0;
}
@media (max-width: 1180px) {
.apps-fab-wrap {
bottom: calc(max(0.8rem, env(safe-area-inset-bottom)) + 5.9rem);
}
}
</style>
@@ -1,161 +0,0 @@
<script lang="ts">
let {
quickMenuOpen = $bindable(false),
canOpenMixMaster,
canCreateMixWorksheet,
canOpenMixCalculator,
canCreateMixSession,
canUseWorkspaceSearch,
onOpenPalette
}: {
quickMenuOpen?: boolean;
canOpenMixMaster: boolean;
canCreateMixWorksheet: boolean;
canOpenMixCalculator: boolean;
canCreateMixSession: boolean;
canUseWorkspaceSearch: boolean;
onOpenPalette: () => void;
} = $props();
</script>
{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canUseWorkspaceSearch}
<div class="quick-fab-wrap">
{#if quickMenuOpen}
<div class="menu-panel quick-fab-panel">
{#if canOpenMixMaster}
<a href="/mixes">Open mix costing</a>
{/if}
{#if canCreateMixWorksheet}
<a href="/mixes/new">Create mix worksheet</a>
{/if}
{#if canOpenMixCalculator}
<a href="/mix-calculator">Open mix calculator</a>
{/if}
{#if canCreateMixSession}
<a href="/mix-calculator">Create mix session</a>
{/if}
{#if canUseWorkspaceSearch}
<button type="button" onclick={onOpenPalette}>Search the workspace</button>
{/if}
</div>
{/if}
<button
aria-expanded={quickMenuOpen}
aria-label="Open quick access menu"
class="quick-fab"
type="button"
onclick={() => (quickMenuOpen = !quickMenuOpen)}
>
<span class={`quick-fab-plus ${quickMenuOpen ? 'open' : ''}`}></span>
<span>Quick Access</span>
</button>
</div>
{/if}
<style>
.menu-panel {
position: absolute;
top: calc(100% + 0.45rem);
right: 0;
z-index: 20;
min-width: 13rem;
display: grid;
gap: 0.18rem;
padding: 0.4rem;
border: 1px solid var(--color-border);
border-radius: 0.96rem;
background: var(--color-bg-elevated);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.quick-fab-wrap {
position: fixed;
right: max(1rem, env(safe-area-inset-right));
bottom: max(1rem, env(safe-area-inset-bottom));
z-index: 46;
display: grid;
justify-items: end;
gap: 0.6rem;
}
.quick-fab {
display: inline-flex;
align-items: center;
gap: 0.72rem;
padding: 0.88rem 1.05rem;
border: none;
border-radius: 999px;
background: var(--color-brand);
color: var(--color-on-brand);
box-shadow: none;
font-weight: 700;
letter-spacing: 0.01em;
cursor: pointer;
}
.quick-fab-panel {
position: static;
min-width: 15rem;
padding: 0.45rem;
border-radius: 1rem;
}
.quick-fab-plus {
position: relative;
width: 0.92rem;
height: 0.92rem;
flex-shrink: 0;
}
.quick-fab-plus::before,
.quick-fab-plus::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0.92rem;
height: 2px;
border-radius: 999px;
background: currentColor;
transform: translate(-50%, -50%);
transition: transform 140ms ease;
}
.quick-fab-plus::after {
transform: translate(-50%, -50%) rotate(90deg);
}
.quick-fab-plus.open::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.quick-fab-plus.open::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.menu-panel a,
.menu-panel button {
padding: 0.72rem 0.78rem;
border-radius: 0.78rem;
color: var(--color-text-primary);
text-align: left;
background: transparent;
border: none;
}
.menu-panel button {
cursor: pointer;
}
.menu-panel a:hover,
.menu-panel button:hover {
background: var(--panel-soft);
}
@media (max-width: 1180px) {
.quick-fab-wrap {
bottom: calc(max(0.8rem, env(safe-area-inset-bottom)) + 5.9rem);
}
}
</style>
@@ -1,201 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { SearchItem } from '$lib/navigation/client-navigation';
let {
query = $bindable(''),
filteredSearchItems,
hiddenResultCount,
onClose,
onRunSearchItem
}: {
query?: string;
filteredSearchItems: SearchItem[];
hiddenResultCount: number;
onClose: () => void;
onRunSearchItem: (item: SearchItem) => void | Promise<void>;
} = $props();
let paletteInput: HTMLInputElement | null = null;
onMount(() => {
paletteInput?.focus();
});
</script>
<div class="palette-overlay" role="presentation" onclick={onClose}>
<div
class="palette"
role="dialog"
aria-modal="true"
aria-label="Workspace search"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => {
if (event.key === 'Escape') {
onClose();
}
}}
>
<div class="palette-input-row">
<span class="search-icon"></span>
<input bind:this={paletteInput} bind:value={query} placeholder="Search mixes, sessions, and pages..." />
<kbd>Esc</kbd>
</div>
<div class="palette-results">
{#if filteredSearchItems.length}
{#each filteredSearchItems as item}
<button class="palette-item" type="button" onclick={() => onRunSearchItem(item)}>
<div>
<strong>{item.label}</strong>
<span>{item.description}</span>
</div>
<small>{item.href}</small>
</button>
{/each}
{#if hiddenResultCount > 0}
<p class="palette-more">{hiddenResultCount} more {hiddenResultCount === 1 ? 'match' : 'matches'}, keep typing to narrow.</p>
{/if}
{:else}
<div class="palette-empty">
<strong>No results</strong>
<span>Try searching for mixes, sessions, or pages.</span>
</div>
{/if}
</div>
</div>
</div>
<style>
.palette-overlay {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: start center;
padding: 8vh 1rem 1rem;
background: rgba(11, 18, 14, 0.3);
backdrop-filter: blur(10px);
}
.palette {
width: min(44rem, 100%);
border: 1px solid var(--color-border);
border-radius: 1.2rem;
background: var(--color-bg-surface);
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
overflow: hidden;
}
.palette-input-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.8rem;
padding: 0.95rem 1rem;
border-bottom: 1px solid var(--line);
}
.palette-input-row input {
border: none;
outline: none;
background: transparent;
color: var(--text);
font-size: 0.98rem;
}
.palette-results {
max-height: 26rem;
overflow: auto;
padding: 0.5rem;
}
.palette-item,
.palette-empty {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.88rem 0.92rem;
border: none;
border-radius: 0.92rem;
text-align: left;
background: transparent;
}
.palette-item {
cursor: pointer;
}
.palette-item:hover {
background: var(--panel-soft);
}
.palette-item strong,
.palette-empty strong {
display: block;
font-size: 0.96rem;
}
.palette-item span,
.palette-empty span,
.palette-item small {
color: var(--muted);
}
.palette-item span {
display: block;
margin-top: 0.18rem;
font-size: 0.84rem;
}
.palette-item small {
flex-shrink: 0;
font-size: 0.76rem;
}
.palette-empty {
justify-content: flex-start;
}
.palette-more {
margin: 0.25rem 0.4rem 0.15rem;
padding: 0.5rem 0.52rem 0.2rem;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.78rem;
}
.search-icon {
position: relative;
display: inline-block;
width: 0.82rem;
height: 0.82rem;
border: 2px solid var(--color-text-muted);
border-radius: 999px;
}
.search-icon::after {
content: '';
position: absolute;
right: -0.28rem;
bottom: -0.18rem;
width: 0.42rem;
height: 2px;
border-radius: 999px;
background: var(--color-text-muted);
transform: rotate(45deg);
}
kbd {
padding: 0.1rem 0.42rem;
border: 1px solid var(--line-strong);
border-radius: 0.42rem;
color: var(--muted);
background: var(--color-bg-surface);
font-size: 0.76rem;
}
</style>
@@ -1,9 +1,8 @@
<script lang="ts">
import { Calculator, LogOut, Menu, Plus, Search, Settings } from 'lucide-svelte';
import { Calculator, LogOut, Menu, Plus, Settings } from 'lucide-svelte';
import type { FooterLink, NavGroup, NavItem } from '$lib/navigation/client-navigation';
import { matchesRoute } from '$lib/navigation/client-navigation';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
let {
showBottomNav,
@@ -25,9 +24,7 @@
canCreateMixWorksheet,
canCreateMixSession,
canOpenSettings,
canUseWorkspaceSearch,
pagePath,
onOpenPalette,
onOpenSettings,
onSignOut
}: {
@@ -50,9 +47,7 @@
canCreateMixWorksheet: boolean;
canCreateMixSession: boolean;
canOpenSettings: boolean;
canUseWorkspaceSearch: boolean;
pagePath: string;
onOpenPalette: () => void;
onOpenSettings: () => void | Promise<void>;
onSignOut: () => void | Promise<void>;
} = $props();
@@ -96,12 +91,6 @@
</button>
</div>
<WorkspaceSearchTrigger
className="drawer-search"
placeholder="Search the workspace..."
onClick={() => canUseWorkspaceSearch && onOpenPalette()}
/>
<div class="drawer-grid">
<nav class="drawer-section" aria-label="All workspace pages">
{#if visibleDashboardItem}
@@ -210,12 +199,6 @@
<span>Change settings</span>
</button>
{/if}
{#if canUseWorkspaceSearch}
<button type="button" onclick={onOpenPalette}>
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
<span>Search the workspace</span>
</button>
{/if}
<button type="button" onclick={onSignOut}>
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
<span>Logout</span>
@@ -404,10 +387,6 @@
font-size: 1rem;
}
:global(.drawer-search) {
background: var(--color-bg-surface);
}
.drawer-grid {
display: grid;
gap: 0.9rem;
@@ -299,13 +299,7 @@
<a href="/">Return to sign-in</a>
</section>
{:else}
<section class="page-intro">
<div>
<p class="eyebrow">{savedMix ? 'Edit Mix' : 'New Mix'}</p>
<h2>{savedMix ? `Editing ${savedMix.name}` : 'Create a new costing worksheet'}</h2>
<p>Use ingredient rows like a spreadsheet, with live costing based on market value, waste, and unit conversion.</p>
</div>
<section class="page-intro page-actions">
<div class="intro-actions">
<a class="secondary-button" href="/mixes">Back to table</a>
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
@@ -584,15 +578,13 @@
max-width: 40rem;
}
.locked-card h2,
.page-intro h2 {
.locked-card h2 {
margin: 0.3rem 0 0.4rem;
font-size: clamp(1.56rem, 3vw, 2.02rem);
font-weight: 700;
}
.locked-card p:last-of-type,
.page-intro p:last-child,
.metric-card p,
.summary-card span,
.factor-list span,
@@ -619,6 +611,10 @@
align-items: end;
}
.page-actions {
justify-content: flex-end;
}
.primary-button,
.secondary-button {
display: inline-flex;
@@ -2,6 +2,7 @@
import { untrack } from 'svelte';
import { ChevronDown, LogOut, Settings } from 'lucide-svelte';
import type { ComponentType } from 'svelte';
import { tooltip } from '$lib/actions/tooltip';
import {
groupHasActiveChild,
@@ -14,6 +15,7 @@
let {
brandHref,
currentPath,
collapsed = false,
entries,
footerItems,
appVersion,
@@ -24,6 +26,7 @@
}: {
brandHref: string;
currentPath: string;
collapsed?: boolean;
entries: NavEntry[];
footerItems: FooterLink[];
appVersion: string;
@@ -97,7 +100,7 @@
const isOpen = (id: string) => openGroups[id] ?? false;
// Accordion: opening a group collapses every other group; closing just shuts
// the one. So expanding Order Management folds an already-open Costing away.
// the one. So expanding Order Management folds an already-open Operations away.
function toggleGroup(id: string) {
openGroups = isOpen(id) ? {} : { [id]: true };
persistOpenState();
@@ -131,6 +134,7 @@
let openSubGroups = $state<Record<string, boolean>>(restoreSubState());
let lastAutoExpandedSub = $state<string | null>(null);
let logoutConfirmOpen = $state(false);
function persistSubState() {
if (typeof window === 'undefined') return;
@@ -191,40 +195,81 @@
openSubGroups = { ...openSubGroups, [key]: true };
persistSubState();
}
function handleLogoutAction() {
if (collapsed) {
logoutConfirmOpen = true;
return;
}
onSignOut();
}
function closeLogoutConfirm() {
logoutConfirmOpen = false;
}
function confirmSignOut() {
logoutConfirmOpen = false;
onSignOut();
}
</script>
{#snippet leafLink(item: NavItem, showIcon: boolean)}
{@const Icon = item.icon}
<a class="rail-row" class:active={matchesRoute(item.href, currentPath, item.exact)} href={item.href}>
<a
class="rail-row"
class:active={matchesRoute(item.href, currentPath, item.exact)}
class:icon-only={collapsed}
href={item.href}
use:tooltip={collapsed ? { label: item.label, placement: 'right' } : ''}
>
{#if showIcon && Icon}
<span class="rail-icon"><Icon size={18} strokeWidth={1.75} /></span>
{/if}
<span class="rail-text">{item.label}</span>
{#if item.badge}<span class="rail-badge">{item.badge}</span>{/if}
{#if !collapsed}
<span class="rail-text">{item.label}</span>
{#if item.badge}<span class="rail-badge">{item.badge}</span>{/if}
{/if}
</a>
{/snippet}
{#snippet actionRow(label: string, Icon: ComponentType, active: boolean, onSelect: () => void)}
{@const RowIcon = Icon}
<button type="button" class="rail-row" class:active onclick={onSelect}>
<button
type="button"
class="rail-row"
class:active
class:icon-only={collapsed}
onclick={onSelect}
use:tooltip={collapsed ? { label, placement: 'right' } : ''}
>
<span class="rail-icon"><RowIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{label}</span>
{#if !collapsed}
<span class="rail-text">{label}</span>
{/if}
</button>
{/snippet}
<aside class="sidebar">
<aside class:collapsed={collapsed} class="sidebar">
<div class="brand-row">
<a class="brand" href={brandHref}>
<span class="brand-kicker">Hunter App</span>
<span class="brand-wordmark">Hunter Premium Produce</span>
<span class="brand-subtitle">Operations workspace</span>
{#if !collapsed}
<span class="brand-kicker">Hunter App</span>
<span class="brand-wordmark">Hunter Premium Produce</span>
<span class="brand-subtitle">Operations workspace</span>
{:else}
<span class="brand-mini">HP</span>
{/if}
</a>
</div>
<div class="sidebar-body">
<div class="rail-scroll">
<div class="rail-section-head">
<p class="rail-section-label">Modules</p>
{#if !collapsed}
<p class="rail-section-label">Modules</p>
{/if}
</div>
<nav class="rail-nav" aria-label="Workspace navigation">
@@ -237,11 +282,21 @@
{@const groupActive = groupHasActiveChild(group, currentPath)}
{@const open = isOpen(group.id)}
{@const headerHref = group.href ?? group.children[0]?.href}
{#if collapsed}
<a
class="rail-row icon-only"
class:active={groupActive}
href={headerHref}
use:tooltip={{ label: group.label, placement: 'right' }}
>
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
</a>
{:else}
<div class="rail-group">
{#if headerHref}
<!-- Every family header both navigates and reveals its submenu:
the label goes to the family's landing route (Order Management
→ its queue; Costing → its first tool) and opens the child
→ its queue; Operations → its first tool) and opens the child
list, while the chevron toggles independently. The header never
takes the full active pill — that belongs to the matching child
row — it only gets the subtle within-active emphasis when
@@ -330,6 +385,7 @@
</div>
{/if}
</div>
{/if}
{/if}
{/each}
</nav>
@@ -344,29 +400,52 @@
{#if canOpenSettings}
{@render actionRow('Settings', Settings, currentPath.startsWith('/settings'), onOpenSettings)}
{/if}
{@render actionRow('Logout', LogOut, false, onSignOut)}
{@render actionRow('Logout', LogOut, false, handleLogoutAction)}
</div>
<div class="sidebar-meta-foot">
<div class="sidebar-meta-top">
<span class="version-pill">
<span class="meta-label">Build</span>
<span>{appVersion}</span>
</span>
</div>
<div class="sidebar-meta-bottom">
<small>&copy; {currentYear} Hunter Premium Produce</small>
<div class="powered-by">
<span>Powered by</span>
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
<strong>Lean 101</strong>
{#if !collapsed}
<div class="sidebar-meta-foot">
<div class="sidebar-meta-top">
<span class="version-pill">
<span class="meta-label">Build</span>
<span>{appVersion}</span>
</span>
</div>
<div class="sidebar-meta-bottom">
<small>&copy; {currentYear} Hunter Premium Produce</small>
<div class="powered-by">
<span>Powered by</span>
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
<strong>Lean 101</strong>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
</aside>
{#if logoutConfirmOpen}
<div class="logout-dialog-backdrop" aria-hidden="true" onclick={closeLogoutConfirm}></div>
<div
class="logout-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="logout-dialog-title"
aria-describedby="logout-dialog-description"
>
<div class="logout-dialog-copy">
<p class="logout-dialog-kicker">Confirm Logout</p>
<h2 id="logout-dialog-title">Log out of the workspace?</h2>
<p id="logout-dialog-description">Your current client session will be closed and you will return to sign-in.</p>
</div>
<div class="logout-dialog-actions">
<button type="button" class="logout-dialog-cancel" onclick={closeLogoutConfirm}>Cancel</button>
<button type="button" class="logout-dialog-confirm" onclick={confirmSignOut}>Log out</button>
</div>
</div>
{/if}
<style>
/* Monochrome rail with a blue selected pill. Colours come from the --sidebar-*
tokens, which are overridden in dark mode (see theme.css) so the rail themes
@@ -384,6 +463,11 @@
overflow: hidden;
}
.sidebar.collapsed {
align-items: center;
padding: 1rem 0.5rem 0.85rem;
}
.rail-section-label {
margin: 0;
color: var(--sidebar-text-muted);
@@ -438,6 +522,20 @@
padding: 0.08rem 0 0.1rem;
}
.brand-mini {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.9rem;
background: var(--sidebar-active-bg);
color: var(--sidebar-active-text);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
}
.brand-kicker {
color: var(--sidebar-text-muted);
font-size: 0.66rem;
@@ -466,6 +564,10 @@
gap: 0.18rem;
}
.sidebar.collapsed .rail-nav {
justify-items: center;
}
.rail-row {
position: relative;
display: flex;
@@ -544,6 +646,16 @@
line-height: 1.5;
}
.rail-row.icon-only {
justify-content: center;
gap: 0;
width: 3rem;
min-width: 3rem;
min-height: 3rem;
padding: 0;
border-radius: 0.95rem;
}
.rail-row.active .rail-badge {
border-color: color-mix(in srgb, var(--sidebar-active-text) 26%, transparent);
color: var(--sidebar-active-text);
@@ -735,6 +847,24 @@
flex-shrink: 0;
}
.sidebar.collapsed .sidebar-body,
.sidebar.collapsed .rail-scroll,
.sidebar.collapsed .sidebar-meta {
align-items: center;
}
.sidebar.collapsed .rail-section-head {
justify-content: center;
padding: 0;
min-height: 0.35rem;
}
.sidebar.collapsed .brand-row,
.sidebar.collapsed .sidebar-meta,
.sidebar.collapsed .sidebar-meta-foot {
width: 100%;
}
.sidebar-meta-foot {
display: grid;
gap: 0.55rem;
@@ -809,4 +939,91 @@
letter-spacing: 0.06em;
text-transform: uppercase;
}
.logout-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 39;
background: rgba(15, 23, 42, 0.22);
}
.logout-dialog {
position: fixed;
top: 50%;
left: 50%;
z-index: 40;
width: min(28rem, calc(100vw - 2rem));
display: grid;
gap: 1rem;
padding: 1.1rem;
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-bg-elevated);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
transform: translate(-50%, -50%);
}
.logout-dialog-copy {
display: grid;
gap: 0.35rem;
}
.logout-dialog-kicker {
margin: 0;
color: var(--color-text-primary);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.logout-dialog-copy h2,
.logout-dialog-copy p {
margin: 0;
}
.logout-dialog-copy h2 {
font-size: 1.1rem;
line-height: 1.2;
}
.logout-dialog-copy p:last-child {
color: var(--color-text-secondary);
line-height: 1.5;
}
.logout-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.7rem;
}
.logout-dialog-cancel,
.logout-dialog-confirm {
min-height: 2.7rem;
padding: 0.65rem 0.95rem;
border-radius: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.logout-dialog-cancel {
border: 1px solid var(--color-border);
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
.logout-dialog-confirm {
border: 1px solid transparent;
background: var(--color-brand);
color: var(--color-on-brand);
}
.logout-dialog-cancel:hover {
background: var(--panel-soft);
}
.logout-dialog-confirm:hover {
background: var(--color-brand-hover);
}
</style>
@@ -1,36 +1,48 @@
<script lang="ts">
import { Settings, Sparkles } from 'lucide-svelte';
import { PanelLeft, Settings, Sparkles } from 'lucide-svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
import WorkspaceSearchField from '$lib/components/navigation/WorkspaceSearchField.svelte';
import type { SearchItem } from '$lib/navigation/client-navigation';
import { tooltip } from '$lib/actions/tooltip';
import type { AppSession } from '$lib/session';
import type { Crumb } from '$lib/navigation/client-navigation';
let {
breadcrumbs,
title,
sessionHydrated,
session,
showSidebarToggle,
sidebarOpen,
userInitials,
userMenuOpen,
canUseWorkspaceSearch,
searchQuery = $bindable(''),
searchOpen = $bindable(false),
searchFocusRequest,
filteredSearchItems,
hiddenResultCount,
canOpenSettings,
onOpenPalette,
onRunSearchItem,
onToggleSidebar,
onToggleUserMenu,
onOpenSettings,
onSignOut,
onShowWhatsNew
}: {
breadcrumbs: Crumb[];
title: string;
sessionHydrated: boolean;
session: AppSession | null;
showSidebarToggle: boolean;
sidebarOpen: boolean;
userInitials: string;
userMenuOpen: boolean;
canUseWorkspaceSearch: boolean;
searchQuery?: string;
searchOpen?: boolean;
searchFocusRequest: number;
filteredSearchItems: SearchItem[];
hiddenResultCount: number;
canOpenSettings: boolean;
onOpenPalette: () => void;
onRunSearchItem: (item: SearchItem) => void | Promise<void>;
onToggleSidebar: () => void;
onToggleUserMenu: () => void;
onOpenSettings: () => void;
onSignOut: () => void;
@@ -40,27 +52,35 @@
<header class="topbar">
<div class="topbar-start">
{#if showSidebarToggle}
<button
class="sidebar-toggle"
type="button"
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
aria-pressed={sidebarOpen}
onclick={onToggleSidebar}
use:tooltip={sidebarOpen ? 'Hide menu' : 'Show menu'}
>
<PanelLeft size={17} strokeWidth={1.9} />
</button>
{/if}
<a class="topbar-brand" href="/" aria-label="Hunter Premium Produce home">
<img src="/logo-hsf.png" alt="Hunter Premium Produce" />
<img src="/hunter-logo-sidebar.png" alt="Hunter Premium Produce" />
</a>
<div class="topbar-copy">
<nav class="breadcrumbs" aria-label="Breadcrumb">
{#each breadcrumbs as crumb, index}
{#if index > 0}<span class="breadcrumb-sep" aria-hidden="true">/</span>{/if}
{#if crumb.href && index < breadcrumbs.length - 1}
<a href={crumb.href}>{crumb.label}</a>
{:else}
<span aria-current={index === breadcrumbs.length - 1 ? 'page' : undefined}>{crumb.label}</span>
{/if}
{/each}
</nav>
<h1>{title}</h1>
</div>
</div>
{#if canUseWorkspaceSearch}
<div class="topbar-middle">
<WorkspaceSearchTrigger className="topbar-search" onClick={onOpenPalette} />
<WorkspaceSearchField
bind:query={searchQuery}
bind:open={searchOpen}
focusRequest={searchFocusRequest}
filteredSearchItems={filteredSearchItems}
{hiddenResultCount}
className="topbar-search"
onRunSearchItem={onRunSearchItem}
/>
</div>
{:else}
<div class="topbar-middle"></div>
@@ -135,10 +155,7 @@
<style>
.topbar {
display: grid;
/* Left flexes/truncates, search shrinks within a cap, and the actions take
exactly their content width (auto) so they're never squeezed into
wrapping. */
grid-template-columns: minmax(0, 1fr) minmax(0, 30rem) auto;
grid-template-columns: minmax(0, 1fr) minmax(20rem, 3fr) minmax(0, 1fr);
align-items: center;
gap: 0.75rem;
padding: 0.72rem 1.2rem;
@@ -174,61 +191,41 @@
transform-origin: left center;
}
.topbar-copy {
min-width: 0;
}
.topbar-copy h1 {
margin: 0.12rem 0 0;
font-size: 1.34rem;
font-weight: 700;
letter-spacing: -0.01em;
/* Yield gracefully when space is tight rather than pushing the actions
into a wrap. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.breadcrumbs {
display: flex;
.sidebar-toggle {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.32rem;
color: var(--muted);
font-size: 0.74rem;
font-weight: 500;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
flex-shrink: 0;
border: 1px solid transparent;
border-radius: 0.7rem;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.breadcrumbs a {
color: var(--muted);
transition: color 140ms ease;
}
.breadcrumbs a:hover {
color: var(--green-deep);
}
.breadcrumbs span[aria-current='page'] {
color: var(--text);
font-weight: 600;
}
.breadcrumb-sep {
color: var(--color-text-muted);
font-size: 0.78rem;
.sidebar-toggle:hover {
background: var(--panel-soft);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.topbar-middle {
min-width: 0;
display: flex;
justify-content: center;
/* Fill the grid track; justify-self:center would collapse this to the
field's content width and clip the placeholder. */
justify-self: stretch;
padding-left: 0.9rem;
}
:global(.topbar-search) {
width: 100%;
min-height: 2.75rem;
background: color-mix(in srgb, var(--panel-soft) 60%, var(--color-bg-surface));
/* ~half the topbar on a large laptop; centered within its track. */
max-width: 40rem;
}
.topbar-actions {
@@ -238,6 +235,7 @@
/* Never let the What's-new / theme toggles wrap above the user button. */
flex-wrap: nowrap;
justify-content: flex-end;
justify-self: end;
}
/* Matches the ThemeToggle button so the two sit as a pair. */
@@ -447,7 +445,7 @@
/* Drop the search to its own row early: with the 252px sidebar present, a
laptop's content width is already tight well above the sidebar's own
collapse point, so keep the top row to brand + actions only. */
@media (max-width: 1280px) {
@media (max-width: 1180px) {
.topbar {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
@@ -461,6 +459,17 @@
.topbar-middle {
grid-area: middle;
/* Span the full row instead of shrinking to the field's content width. */
justify-self: stretch;
padding-left: 0;
}
/* On its own row the field gets the whole width, which is too much — keep it
to ~half, centered, so it reads as a search bar rather than a banner. */
:global(.topbar-search) {
width: 55%;
min-width: 26rem;
max-width: 40rem;
}
.topbar-actions {
@@ -0,0 +1,89 @@
<script lang="ts">
import type { ComponentType } from 'svelte';
let {
category,
title,
icon
}: {
category: string;
title: string;
icon: ComponentType;
} = $props();
const Icon = $derived(icon);
</script>
<section class="page-header" aria-label={`${title} page header`}>
<div class="page-header-icon" aria-hidden="true">
<Icon size={20} strokeWidth={1.9} />
</div>
<div class="page-header-copy">
<p>{category}</p>
<h1>{title}</h1>
</div>
</section>
<style>
.page-header {
display: flex;
align-items: center;
gap: 0.95rem;
margin-bottom: 1.15rem;
padding: 1rem 1.1rem;
border: 1px solid var(--line);
border-radius: 1.15rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.page-header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.75rem;
height: 2.75rem;
border-radius: 0.9rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
background: color-mix(in srgb, var(--color-brand-tint) 72%, var(--color-bg-surface));
color: var(--color-brand);
}
.page-header-copy {
min-width: 0;
}
.page-header-copy p {
margin: 0 0 0.2rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.page-header-copy h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
}
@media (max-width: 720px) {
.page-header {
padding: 0.92rem 0.95rem;
}
.page-header-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.82rem;
}
.page-header-copy h1 {
font-size: 1.28rem;
}
}
</style>
@@ -0,0 +1,393 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { pageMeta, type SearchItem } from '$lib/navigation/client-navigation';
let {
query = $bindable(''),
open = $bindable(false),
focusRequest = 0,
filteredSearchItems,
hiddenResultCount,
label = 'Search the workspace',
placeholder = 'Search products, mixes, sessions, and pages...',
className = '',
showShortcut = true,
onRunSearchItem
}: {
query?: string;
open?: boolean;
focusRequest?: number;
filteredSearchItems: SearchItem[];
hiddenResultCount: number;
label?: string;
placeholder?: string;
className?: string;
showShortcut?: boolean;
onRunSearchItem: (item: SearchItem) => void | Promise<void>;
} = $props();
let root: HTMLDivElement | null = null;
let input: HTMLInputElement | null = null;
let highlightedIndex = $state(-1);
const resultsId = `workspace-search-results-${Math.random().toString(36).slice(2)}`;
const activeDescendant = $derived(
highlightedIndex >= 0 ? `${resultsId}-${highlightedIndex}` : undefined
);
function openSearch() {
open = true;
}
function closeSearch() {
open = false;
highlightedIndex = -1;
}
async function selectItem(item: SearchItem) {
closeSearch();
await onRunSearchItem(item);
}
function moveHighlight(direction: 1 | -1) {
if (!filteredSearchItems.length) {
highlightedIndex = -1;
return;
}
open = true;
if (highlightedIndex === -1) {
highlightedIndex = direction === 1 ? 0 : filteredSearchItems.length - 1;
return;
}
highlightedIndex = (highlightedIndex + direction + filteredSearchItems.length) % filteredSearchItems.length;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
moveHighlight(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
moveHighlight(-1);
return;
}
if (event.key === 'Enter' && open) {
const candidate =
highlightedIndex >= 0 ? filteredSearchItems[highlightedIndex] : filteredSearchItems[0];
if (candidate) {
event.preventDefault();
void selectItem(candidate);
}
return;
}
if (event.key === 'Escape') {
event.preventDefault();
closeSearch();
}
}
$effect(() => {
focusRequest;
if (!open) {
return;
}
tick().then(() => input?.focus());
});
$effect(() => {
if (!open) {
highlightedIndex = -1;
return;
}
if (!filteredSearchItems.length) {
highlightedIndex = -1;
return;
}
if (highlightedIndex >= filteredSearchItems.length) {
highlightedIndex = filteredSearchItems.length - 1;
}
});
onMount(() => {
const handlePointerDown = (event: MouseEvent) => {
if (root?.contains(event.target as Node)) {
return;
}
closeSearch();
};
window.addEventListener('mousedown', handlePointerDown);
return () => {
window.removeEventListener('mousedown', handlePointerDown);
};
});
</script>
<div bind:this={root} class={`workspace-search ${className}`.trim()}>
<label class="search-box">
<span class="search-icon" aria-hidden="true"></span>
<input
bind:this={input}
bind:value={query}
type="search"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-label={label}
aria-expanded={open}
aria-controls={resultsId}
aria-activedescendant={activeDescendant}
aria-autocomplete="list"
placeholder={placeholder}
onfocus={openSearch}
oninput={openSearch}
onkeydown={handleKeydown}
/>
{#if showShortcut}
<kbd>/</kbd>
{/if}
</label>
{#if open}
<div class="search-results" id={resultsId} role="listbox">
{#if filteredSearchItems.length}
{#each filteredSearchItems as item, index (item.href + item.label)}
{@const ResultIcon = pageMeta(item.href).icon}
<button
id={`${resultsId}-${index}`}
class:active={index === highlightedIndex}
class="search-result"
type="button"
role="option"
aria-selected={index === highlightedIndex}
onmouseenter={() => (highlightedIndex = index)}
onclick={() => void selectItem(item)}
>
<span class="search-result-icon" aria-hidden="true">
<ResultIcon size={18} strokeWidth={1.9} />
</span>
<div class="search-result-copy">
<strong>{item.label}</strong>
<span>{item.description}</span>
</div>
<small>{item.href}</small>
</button>
{/each}
{#if hiddenResultCount > 0}
<p class="search-more">
{hiddenResultCount} more {hiddenResultCount === 1 ? 'match' : 'matches'}, keep typing to narrow.
</p>
{/if}
{:else}
<div class="search-empty">
<strong>No results</strong>
<span>Try searching for mixes, sessions, or pages.</span>
</div>
{/if}
</div>
{/if}
</div>
<style>
.workspace-search {
position: relative;
width: 100%;
min-width: 0;
}
.search-box {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.64rem;
width: 100%;
min-height: 2.75rem;
padding: 0.72rem 0.82rem;
border: 1px solid var(--line);
border-radius: 0.82rem;
background: color-mix(in srgb, var(--panel-soft) 60%, var(--color-bg-surface));
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
}
.workspace-search:focus-within .search-box,
.search-box:hover {
border-color: color-mix(in srgb, var(--color-brand) 24%, var(--line));
background: var(--color-bg-surface);
}
.workspace-search:focus-within .search-box {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
input {
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--color-text-primary);
font: inherit;
}
input::placeholder {
color: var(--color-text-muted);
}
.search-icon {
position: relative;
display: inline-block;
width: 0.82rem;
height: 0.82rem;
border: 2px solid var(--color-text-muted);
border-radius: 999px;
}
.search-icon::after {
content: '';
position: absolute;
right: -0.28rem;
bottom: -0.18rem;
width: 0.42rem;
height: 2px;
border-radius: 999px;
background: var(--color-text-muted);
transform: rotate(45deg);
}
.search-results {
position: absolute;
top: calc(100% + 0.45rem);
left: 0;
right: 0;
z-index: 35;
display: grid;
gap: 0.18rem;
max-height: min(26rem, calc(100vh - 10rem));
overflow: auto;
padding: 0.46rem;
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-bg-surface);
box-shadow: 0 18px 44px rgba(11, 18, 14, 0.14);
}
.search-result,
.search-empty {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding: 0.7rem 0.8rem;
border: none;
border-radius: 0.82rem;
background: transparent;
text-align: left;
}
.search-result {
cursor: pointer;
transition: background-color 140ms ease;
}
.search-result:hover,
.search-result.active {
background: var(--panel-soft);
}
.search-result-icon {
display: grid;
place-items: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.7rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
background: color-mix(in srgb, var(--color-brand-tint) 72%, var(--color-bg-surface));
color: var(--color-brand);
}
/* Strip the inline-SVG baseline gap so the glyph is truly centred, not nudged
down-and-left inside the badge. */
.search-result-icon :global(svg) {
display: block;
}
.search-result-copy {
min-width: 0;
/* Take the middle slot so the trailing href stays right-aligned. */
flex: 1;
}
.search-result strong,
.search-empty strong {
display: block;
font-size: 0.94rem;
color: var(--color-text-primary);
}
.search-result span,
.search-empty span,
.search-result small,
.search-more {
color: var(--color-text-secondary);
}
.search-result span {
display: block;
margin-top: 0.18rem;
font-size: 0.82rem;
}
.search-result small {
flex-shrink: 0;
font-size: 0.74rem;
}
.search-empty {
justify-content: flex-start;
}
.search-more {
margin: 0.12rem 0.28rem 0;
padding: 0.48rem 0.52rem 0.1rem;
border-top: 1px solid var(--line);
font-size: 0.76rem;
}
kbd {
padding: 0.1rem 0.42rem;
border: 1px solid var(--line-strong);
border-radius: 0.42rem;
color: var(--muted);
background: var(--color-bg-surface);
font-size: 0.76rem;
}
@media (max-width: 720px) {
.search-box {
min-height: 2.55rem;
padding: 0.66rem 0.74rem;
}
.search-results {
max-height: min(22rem, calc(100vh - 8rem));
}
}
</style>
@@ -1,85 +0,0 @@
<script lang="ts">
let {
label = 'Search the workspace',
placeholder = 'Search products, mixes, sessions, and pages...',
className = '',
onClick
}: {
label?: string;
placeholder?: string;
className?: string;
onClick: () => void;
} = $props();
</script>
<button class={`search-box ${className}`.trim()} type="button" aria-label={label} onclick={onClick}>
<span class="search-icon"></span>
<span class="search-placeholder">{placeholder}</span>
<kbd>/</kbd>
</button>
<style>
.search-box {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.64rem;
width: 100%;
padding: 0.72rem 0.82rem;
border: 1px solid var(--line);
border-radius: 0.82rem;
background: var(--panel-soft);
text-align: left;
cursor: pointer;
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
}
.search-box:hover {
border-color: color-mix(in srgb, var(--color-brand) 24%, var(--line));
background: var(--color-bg-surface);
}
.search-box:focus-visible {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
.search-placeholder {
color: var(--color-text-muted);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-icon {
position: relative;
display: inline-block;
width: 0.82rem;
height: 0.82rem;
border: 2px solid var(--color-text-muted);
border-radius: 999px;
}
.search-icon::after {
content: '';
position: absolute;
right: -0.28rem;
bottom: -0.18rem;
width: 0.42rem;
height: 2px;
border-radius: 999px;
background: var(--color-text-muted);
transform: rotate(45deg);
}
kbd {
padding: 0.1rem 0.42rem;
border: 1px solid var(--line-strong);
border-radius: 0.42rem;
color: var(--muted);
background: var(--color-bg-surface);
font-size: 0.76rem;
}
</style>
@@ -0,0 +1,314 @@
<script lang="ts">
import { Search } from 'lucide-svelte';
import { label } from '$lib/ordering/format';
import type { CustomerVisibilityRow } from '$lib/types';
let {
rows,
onToggle,
onBulk
}: {
rows: CustomerVisibilityRow[];
onToggle: (row: CustomerVisibilityRow) => void | Promise<void>;
onBulk: (productIds: number[], visible: boolean) => void | Promise<void>;
} = $props();
let query = $state('');
const visibleCount = $derived(rows.filter((r) => r.visible).length);
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) => r.name.toLowerCase().includes(q) || r.sku.toLowerCase().includes(q)
);
});
// Group the filtered rows by category, keeping first-seen order so the list is
// stable as the search narrows. Each group carries its own visible tally so the
// header can show progress without a second pass at render time.
const groups = $derived.by(() => {
const map = new Map<string, CustomerVisibilityRow[]>();
for (const row of filtered) {
const list = map.get(row.category) ?? [];
list.push(row);
map.set(row.category, list);
}
return [...map.entries()].map(([category, items]) => ({
category,
items,
visible: items.filter((i) => i.visible).length
}));
});
/** Product ids in `set` whose current visibility differs from `target`. */
function idsToChange(set: CustomerVisibilityRow[], target: boolean): number[] {
return set.filter((r) => r.visible !== target).map((r) => r.product_id);
}
function setGroup(items: CustomerVisibilityRow[], visible: boolean) {
void onBulk(idsToChange(items, visible), visible);
}
function setFiltered(visible: boolean) {
void onBulk(idsToChange(filtered, visible), visible);
}
</script>
<div class="visibility-manager">
<div class="vis-toolbar">
<label class="vis-search">
<Search size={15} strokeWidth={2} aria-hidden="true" />
<input
type="search"
bind:value={query}
placeholder="Search products or SKU"
aria-label="Search products"
/>
</label>
<div class="vis-summary">
<span class="count-strong">{visibleCount}</span>
<span class="count-of">of {rows.length} visible</span>
</div>
</div>
{#if rows.length}
<div class="vis-bulk">
<span>{filtered.length === rows.length ? 'All products' : `${filtered.length} shown`}</span>
<div class="vis-bulk-actions">
<button class="link" type="button" onclick={() => setFiltered(true)}>Show all</button>
<span class="dot" aria-hidden="true">·</span>
<button class="link" type="button" onclick={() => setFiltered(false)}>Hide all</button>
</div>
</div>
{#if filtered.length}
<div class="vis-groups">
{#each groups as group (group.category)}
<section class="vis-group">
<header class="vis-group-head">
<h4>
{label(group.category)}
<span class="vis-group-count">{group.visible}/{group.items.length}</span>
</h4>
<div class="vis-group-actions">
<button class="link" type="button" onclick={() => setGroup(group.items, true)}>All</button>
<span class="dot" aria-hidden="true">·</span>
<button class="link" type="button" onclick={() => setGroup(group.items, false)}>None</button>
</div>
</header>
<ul class="vis-list">
{#each group.items as row (row.product_id)}
<li>
<label class="vis-row" class:on={row.visible}>
<input
type="checkbox"
checked={row.visible}
onchange={() => onToggle(row)}
/>
<span class="vis-name">{row.name}</span>
<span class="vis-sku">{row.sku}</span>
</label>
</li>
{/each}
</ul>
</section>
{/each}
</div>
{:else}
<p class="vis-empty">No products match “{query}”.</p>
{/if}
{:else}
<p class="vis-empty">No products in the catalogue yet. Add products to control what this customer can order.</p>
{/if}
</div>
<style>
.visibility-manager {
display: grid;
gap: 0.85rem;
}
/* ── Toolbar: search + running visible tally ─────────────────── */
.vis-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.vis-search {
flex: 1;
min-width: 12rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-input-bg);
color: var(--color-text-muted);
transition: border-color 140ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 140ms cubic-bezier(0.22, 1, 0.36, 1);
}
.vis-search:focus-within {
border-color: var(--color-brand);
background: var(--color-bg-surface);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
}
.vis-search input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--color-text-primary);
font: inherit;
}
.vis-summary {
display: inline-flex;
align-items: baseline;
gap: 0.32rem;
white-space: nowrap;
}
.count-strong {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-brand);
font-variant-numeric: tabular-nums;
}
.count-of {
font-size: 0.78rem;
color: var(--color-text-muted);
}
/* ── Bulk row ────────────────────────────────────────────────── */
.vis-bulk {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--color-divider);
font-size: 0.78rem;
color: var(--color-text-muted);
}
.vis-bulk-actions,
.vis-group-actions {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.dot {
color: var(--color-text-muted);
}
/* ── Category groups ─────────────────────────────────────────── */
.vis-groups {
display: grid;
gap: 1.05rem;
}
.vis-group-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.45rem;
}
.vis-group-head h4 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-secondary);
}
.vis-group-count {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.vis-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 0.4rem;
}
.vis-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg-surface);
cursor: pointer;
transition: border-color 140ms cubic-bezier(0.22, 1, 0.36, 1),
background-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
}
.vis-row:hover {
border-color: color-mix(in srgb, var(--color-brand) 35%, var(--color-border));
}
.vis-row.on {
background: color-mix(in srgb, var(--color-brand-tint) 55%, var(--color-bg-surface));
border-color: color-mix(in srgb, var(--color-brand) 28%, var(--color-border));
}
.vis-row input {
accent-color: var(--color-brand);
width: 1rem;
height: 1rem;
cursor: pointer;
}
.vis-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-primary);
}
.vis-sku {
font-size: 0.72rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.vis-empty {
margin: 0.3rem 0;
padding: 1rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius-control);
color: var(--color-text-muted);
font-size: 0.84rem;
text-align: center;
}
</style>
@@ -0,0 +1,546 @@
<script lang="ts">
import { ClipboardList, Clock, Eye, FlaskConical, Info, UserPlus, X } from 'lucide-svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import { tooltip } from '$lib/actions/tooltip';
import { label, money, statusTone } from '$lib/ordering/format';
import CustomerProductVisibility from '$lib/components/ordering/CustomerProductVisibility.svelte';
import type {
CustomerPricing,
CustomerVisibilityRow,
EditorMixRow,
OrderingCustomer,
OrderingCustomerUser,
Order
} from '$lib/types';
let {
customer,
onChanged,
onClose
}: {
customer: OrderingCustomer;
onChanged: (updated?: OrderingCustomer) => void;
onClose: () => void;
} = $props();
type TabId = 'details' | 'access' | 'orders' | 'mixes' | 'history';
const TABS: { id: TabId; label: string; icon: typeof Info }[] = [
{ id: 'details', label: 'Details', icon: Info },
{ id: 'access', label: 'Access', icon: Eye },
{ id: 'orders', label: 'Orders', icon: ClipboardList },
{ id: 'mixes', label: 'Mixes', icon: FlaskConical },
{ id: 'history', label: 'History', icon: Clock }
];
let activeTab = $state<TabId>('details');
let loaded = $state(new Set<TabId>());
let loading = $state(false);
// Per-customer data. Cleared whenever the selected customer changes.
let users = $state<OrderingCustomerUser[]>([]);
let pricing = $state<CustomerPricing | null>(null);
let visibility = $state<CustomerVisibilityRow[]>([]);
let orders = $state<Order[]>([]);
let mixes = $state<EditorMixRow[]>([]);
let notesDraft = $state('');
let addingUser = $state(false);
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
// Reset and reload only when the *id* changes. The parent re-passes a fresh
// customer object after status/notes saves (same id); those must not reset the
// open tab or reload everything.
let lastId = -1;
$effect(() => {
const id = customer.id;
if (id === lastId) return;
lastId = id;
activeTab = 'details';
loaded = new Set();
users = [];
pricing = null;
visibility = [];
orders = [];
mixes = [];
notesDraft = customer.notes ?? '';
addingUser = false;
void loadTab('details');
});
async function loadTab(tab: TabId) {
const id = customer.id;
loading = true;
try {
if (tab === 'details') {
const [u, p] = await Promise.all([
api.orderingAdmin.customerUsers(id),
api.orderingAdmin.pricing(id).catch(() => null)
]);
if (id !== customer.id) return;
users = u;
pricing = p;
} else if (tab === 'access') {
const v = await api.orderingAdmin.visibility(id);
if (id !== customer.id) return;
visibility = v;
} else if (tab === 'orders' || tab === 'history') {
const o = await api.orderingAdmin.orders({ customer_id: id });
if (id !== customer.id) return;
orders = o;
loaded = new Set(loaded).add('orders');
} else if (tab === 'mixes') {
const m = await api.editorMixes({ client_name: customer.name });
if (id !== customer.id) return;
mixes = m;
}
loaded = new Set(loaded).add(tab);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load customer data.');
} finally {
if (id === customer.id) loading = false;
}
}
async function selectTab(tab: TabId) {
activeTab = tab;
if (!loaded.has(tab)) await loadTab(tab);
}
function onTabKeydown(event: KeyboardEvent, index: number) {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
event.preventDefault();
const next = (index + (event.key === 'ArrowRight' ? 1 : TABS.length - 1)) % TABS.length;
void selectTab(TABS[next].id);
}
// ── Mutations ───────────────────────────────────────────────────────────────
async function toggleStatus() {
try {
const updated = await api.orderingAdmin.updateCustomer(customer.id, {
status: customer.status === 'active' ? 'disabled' : 'active'
});
toast.success(`Customer ${updated.status}.`);
onChanged(updated);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function saveNotes() {
try {
const updated = await api.orderingAdmin.updateCustomer(customer.id, { notes: notesDraft });
toast.success('Notes saved.');
onChanged(updated);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save notes.');
}
}
async function addUser() {
if (!newUser.full_name || !newUser.email) return toast.error('Name and email required.');
try {
await api.orderingAdmin.createCustomerUser(customer.id, newUser);
toast.success('User invited.');
newUser = { full_name: '', email: '', role: 'buyer' };
addingUser = false;
users = await api.orderingAdmin.customerUsers(customer.id);
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not add user.');
}
}
async function toggleUserStatus(u: OrderingCustomerUser) {
try {
const next = u.status === 'suspended' ? 'active' : 'suspended';
await api.orderingAdmin.updateCustomerUser(customer.id, u.id, { status: next });
users = await api.orderingAdmin.customerUsers(customer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function toggleVisibility(row: CustomerVisibilityRow) {
try {
await api.orderingAdmin.setVisibility(customer.id, { product_id: row.product_id, visible: !row.visible });
visibility = await api.orderingAdmin.visibility(customer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function bulkVisibility(productIds: number[], visible: boolean) {
if (!productIds.length) return;
const id = customer.id;
try {
await Promise.all(productIds.map((pid) => api.orderingAdmin.setVisibility(id, { product_id: pid, visible })));
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not update visibility.');
} finally {
visibility = await api.orderingAdmin.visibility(id);
}
}
function initials(name: string) {
return (
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('') || '?'
);
}
function fmtDate(value?: string | null) {
return value ? new Date(value).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—';
}
const customPriceCount = $derived(pricing?.product_prices.filter((p) => p.active).length ?? 0);
// Activity timeline merged from order milestones and user invites.
const historyEvents = $derived.by(() => {
const events: { when: string; text: string; tone: string }[] = [];
for (const o of orders) {
const ref = o.order_number ?? `#${o.id}`;
if (o.submitted_at) events.push({ when: o.submitted_at, text: `Order ${ref} submitted`, tone: 'info' });
if (o.updated_at) events.push({ when: o.updated_at, text: `Order ${ref} is ${label(o.status)}`, tone: statusTone(o.status) });
}
for (const u of users) {
if (u.created_at) events.push({ when: u.created_at, text: `Invited ${u.full_name}`, tone: '' });
}
return events
.filter((e) => e.when)
.sort((a, b) => new Date(b.when).getTime() - new Date(a.when).getTime());
});
</script>
<section class="surface-card detail workspace">
<div class="workspace-head">
<div class="detail-head head-row">
<div class="detail-title">
<p class="eyebrow">{customer.client_code}</p>
<h2>{customer.name}</h2>
</div>
<div class="detail-head-actions">
<span class="pill {statusTone(customer.status)}">{customer.status}</span>
<button
class="secondary"
onclick={toggleStatus}
use:tooltip={customer.status === 'active'
? 'Disable ordering for this customer'
: 'Re-enable ordering for this customer'}
>
{customer.status === 'active' ? 'Disable' : 'Enable'}
</button>
<button class="icon-btn" onclick={onClose} aria-label="Close customer" use:tooltip={{ label: 'Close (Esc)', placement: 'bottom' }}>
<X size={17} strokeWidth={2} aria-hidden="true" />
</button>
</div>
</div>
<div class="workspace-tabs" role="tablist" aria-label="Customer sections">
{#each TABS as tab, i (tab.id)}
{@const Icon = tab.icon}
<button
class="workspace-tab"
role="tab"
id={`ws-tab-${tab.id}`}
aria-selected={activeTab === tab.id}
aria-controls={`ws-panel-${tab.id}`}
tabindex={activeTab === tab.id ? 0 : -1}
onclick={() => selectTab(tab.id)}
onkeydown={(e) => onTabKeydown(e, i)}
>
<Icon size={15} strokeWidth={2} aria-hidden="true" />
{tab.label}
{#if tab.id === 'orders' && loaded.has('orders') && orders.length}<span class="tab-count">{orders.length}</span>{/if}
{#if tab.id === 'mixes' && loaded.has('mixes') && mixes.length}<span class="tab-count">{mixes.length}</span>{/if}
</button>
{/each}
</div>
</div>
<div class="workspace-body" role="tabpanel" id={`ws-panel-${activeTab}`} aria-labelledby={`ws-tab-${activeTab}`}>
{#if loading && !loaded.has(activeTab)}
<div class="skeleton" aria-hidden="true">
<span class="skel skel-line"></span>
<span class="skel skel-line short"></span>
<span class="skel skel-block"></span>
</div>
<!-- ── Details ─────────────────────────────────────────────── -->
{:else if activeTab === 'details'}
<div class="ws-section">
<dl class="facts">
<div><dt>Client code</dt><dd>{customer.client_code}</dd></div>
<div><dt>Discount</dt><dd>{customer.discount_percent ? `${customer.discount_percent}%` : 'None'}</dd></div>
<div><dt>Price list</dt><dd>{pricing?.price_list_id ? `#${pricing.price_list_id}` : 'Default'}</dd></div>
<div><dt>Custom prices</dt><dd>{customPriceCount || 'None'}</dd></div>
<div><dt>Xero</dt><dd>{customer.xero_contact_id ? 'Linked' : 'Not linked'}</dd></div>
<div><dt>People</dt><dd>{customer.user_count}</dd></div>
</dl>
</div>
<div class="ws-section">
<div class="section-head"><h3>Notes</h3></div>
<textarea class="notes" rows="3" placeholder="Internal notes about this customer" bind:value={notesDraft}></textarea>
<div class="actions notes-actions">
<button class="secondary" onclick={saveNotes} disabled={(customer.notes ?? '') === notesDraft}>Save notes</button>
</div>
</div>
<div class="ws-section">
<div class="section-head">
<h3>People <span class="count">{users.length}</span></h3>
<button class="link" onclick={() => (addingUser = !addingUser)}>
<UserPlus size={14} strokeWidth={2} aria-hidden="true" />
{addingUser ? 'Cancel' : 'Add person'}
</button>
</div>
{#if users.length}
<ul class="roster">
{#each users as u (u.id)}
<li>
<span class="avatar" aria-hidden="true">{initials(u.full_name)}</span>
<div class="who">
<strong>{u.full_name}</strong>
<span class="who-sub">{u.email}</span>
</div>
<span class="pill role-pill">{label(u.role)}</span>
<span class="pill {statusTone(u.status)}">{u.status}</span>
<button class="link" onclick={() => toggleUserStatus(u)}>
{u.status === 'suspended' ? 'Reactivate' : 'Suspend'}
</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">No people yet. Invite a buyer to give them portal access.</p>
{/if}
{#if addingUser}
<div class="create-panel add-user">
<div class="form-row">
<label>Full name<input placeholder="Jordan Lee" bind:value={newUser.full_name} /></label>
<label>Email<input type="email" placeholder="jordan@acme.com" bind:value={newUser.email} /></label>
<label>Role
<select bind:value={newUser.role}>
<option value="owner">Owner</option>
<option value="buyer">Buyer</option>
<option value="accounts">Accounts</option>
<option value="viewer">Viewer</option>
</select>
</label>
</div>
<div class="actions"><button class="primary" onclick={addUser}>Send invite</button></div>
</div>
{/if}
</div>
<!-- ── Access ──────────────────────────────────────────────── -->
{:else if activeTab === 'access'}
<p class="muted">Choose which products this customer can see and order in the portal.</p>
<CustomerProductVisibility rows={visibility} onToggle={toggleVisibility} onBulk={bulkVisibility} />
<!-- ── Orders ──────────────────────────────────────────────── -->
{:else if activeTab === 'orders'}
{#if orders.length}
<table>
<thead><tr><th>Order</th><th>Placed</th><th>Status</th><th class="amt">Subtotal</th></tr></thead>
<tbody>
{#each orders as o (o.id)}
<tr>
<td class="id-name">{o.order_number ?? `#${o.id}`}</td>
<td>{fmtDate(o.submitted_at ?? o.created_at)}</td>
<td><span class="pill {statusTone(o.status)}">{label(o.status)}</span></td>
<td class="amt">{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="empty">No orders from this customer yet.</p>
{/if}
<!-- ── Mixes ───────────────────────────────────────────────── -->
{:else if activeTab === 'mixes'}
<div class="section-head">
<p class="muted" style="margin:0">Recipes linked to {customer.name}.</p>
<a class="link" href="/editor">Open Mix Editor</a>
</div>
{#if mixes.length}
<table>
<thead><tr><th>Mix</th><th class="amt">Products</th><th>Visible</th></tr></thead>
<tbody>
{#each mixes as m (m.id)}
<tr>
<td class="id-name">{m.name}</td>
<td class="amt">{m.product_count}</td>
<td><span class="pill {m.visible ? 'pos' : 'muted-pill'}">{m.visible ? 'Visible' : 'Hidden'}</span></td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="empty">No mixes matched “{customer.name}”. Mixes link to customers by client name.</p>
{/if}
<!-- ── History ─────────────────────────────────────────────── -->
{:else if activeTab === 'history'}
{#if historyEvents.length}
<ol class="timeline">
{#each historyEvents as e (e.when + e.text)}
<li>
<span class="dot {e.tone}" aria-hidden="true"></span>
<div class="event">
<span class="event-text">{e.text}</span>
<time>{new Date(e.when).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })}</time>
</div>
</li>
{/each}
</ol>
{:else}
<p class="empty">No recorded activity yet.</p>
{/if}
{/if}
</div>
</section>
<style>
.head-row {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 0;
}
/* ── Details: facts grid ──────────────────────────────────────── */
.facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
gap: 0.9rem 1.2rem;
margin: 0;
}
.facts dt {
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-muted);
margin-bottom: 0.15rem;
}
.facts dd {
margin: 0;
font-size: 0.92rem;
font-weight: 600;
color: var(--color-text-primary);
}
.notes {
width: 100%;
padding: 0.6rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-input-bg);
color: var(--color-text-primary);
font: inherit;
resize: vertical;
}
.notes:focus {
outline: none;
border-color: var(--color-brand);
background: var(--color-bg-surface);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
}
.notes-actions { justify-content: flex-end; margin-top: 0.6rem; }
.link { display: inline-flex; align-items: center; gap: 0.32rem; }
.link :global(svg) { display: block; }
/* ── People roster ────────────────────────────────────────────── */
.roster { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.45rem; }
.roster li {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg-surface);
}
.avatar {
display: grid;
place-items: center;
flex-shrink: 0;
width: 2.1rem;
height: 2.1rem;
border-radius: 50%;
background: var(--color-brand-tint);
color: var(--color-brand);
font-size: 0.74rem;
font-weight: 700;
}
.who { flex: 1; min-width: 0; display: grid; gap: 0.1rem; }
.who strong { font-size: 0.86rem; font-weight: 600; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who-sub { font-size: 0.76rem; color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.role-pill { background: color-mix(in srgb, var(--panel-soft) 70%, var(--color-bg-surface)); color: var(--color-text-secondary); }
.add-user { margin-top: 0.7rem; }
.add-user .form-row { margin-bottom: 0.7rem; }
.add-user .form-row label { flex: 1; min-width: 9rem; }
.add-user .actions { justify-content: flex-end; }
.amt { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* ── History timeline ─────────────────────────────────────────── */
.timeline { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
.timeline li { display: flex; gap: 0.75rem; padding: 0.1rem 0; }
.timeline .dot {
flex-shrink: 0;
width: 0.6rem;
height: 0.6rem;
margin-top: 0.4rem;
border-radius: 50%;
background: var(--color-text-muted);
position: relative;
}
/* Connecting line between events. */
.timeline li:not(:last-child) .dot::after {
content: '';
position: absolute;
left: 50%;
top: 0.85rem;
transform: translateX(-50%);
width: 1px;
height: calc(100% + 0.2rem);
background: var(--color-divider);
}
.timeline .dot.pos { background: var(--color-success); }
.timeline .dot.info { background: var(--color-info); }
.timeline .dot.warn { background: var(--color-warning); }
.timeline .dot.danger { background: var(--color-error); }
.timeline .event { display: flex; flex-direction: column; gap: 0.05rem; padding-bottom: 0.85rem; }
.event-text { font-size: 0.85rem; color: var(--color-text-primary); }
.event time { font-size: 0.73rem; color: var(--color-text-muted); }
/* ── Loading skeleton ─────────────────────────────────────────── */
.skeleton { display: grid; gap: 0.7rem; }
.skel {
border-radius: var(--radius-control);
background: linear-gradient(90deg, var(--panel-soft) 25%, var(--color-surface-hover) 37%, var(--panel-soft) 63%);
background-size: 400% 100%;
animation: skel-shimmer 1.4s ease-in-out infinite;
}
.skel-line { height: 1rem; width: 60%; }
.skel-line.short { width: 35%; }
.skel-block { height: 9rem; width: 100%; }
@keyframes skel-shimmer {
0% { background-position: 100% 0; }
100% { background-position: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.skel { animation: none; }
}
</style>
@@ -21,7 +21,7 @@
deletingId,
sortedEntries,
paginatedEntries,
page,
page = $bindable(1),
totalPages,
pageStart,
pageEnd,
@@ -13,6 +13,7 @@ import {
ShieldCheck,
ShoppingCart,
SlidersHorizontal,
Settings,
Tags,
TrendingUp,
Users
@@ -90,6 +91,12 @@ export type Crumb = {
href?: string;
};
export type PageMeta = {
title: string;
category: string;
icon: ComponentType;
};
export const dashboardItem: NavItem = {
href: '/',
label: 'Dashboard',
@@ -285,9 +292,10 @@ export const baseSearchItems: SearchItem[] = [
* Callers pass only the modules the current session may see; empty families
* collapse away so a role with one costing tool never gets an empty group.
*
* Workflow-family layout: Dashboard, then a "Costing" group (the calculator,
* costing, editor, and master tools), then Operations and Insights modules at
* the top level until each grows into a family of its own.
* Workflow-family layout: Dashboard, then an "Operations" group (the calculator,
* costing, editor, master tools, and throughput), then Ordering and Insights
* modules. Costing tools live inside Operations for the time being until they
* grow into a family of their own.
*/
export function buildClientNavEntries(visible: {
dashboard?: NavItem | null;
@@ -302,10 +310,14 @@ export function buildClientNavEntries(visible: {
entries.push({ kind: 'item', item: visible.dashboard });
}
if (visible.costing.length) {
const operationsChildren = [
...visible.costing,
...(visible.throughput ? [visible.throughput] : [])
];
if (operationsChildren.length) {
entries.push({
kind: 'group',
group: { id: 'costing', label: 'Costing', icon: Layers, children: visible.costing }
group: { id: 'operations', label: 'Operations', icon: Layers, children: operationsChildren }
});
}
@@ -313,10 +325,6 @@ export function buildClientNavEntries(visible: {
entries.push(visible.ordering);
}
if (visible.throughput) {
entries.push({ kind: 'item', item: visible.throughput });
}
if (visible.reporting) {
entries.push({ kind: 'item', item: visible.reporting });
}
@@ -358,14 +366,88 @@ export function findOrderingSection(pathname: string): NavItem | null {
}
export function pageTitle(pathname: string) {
return pageMeta(pathname).title;
}
export function pageCategory(pathname: string) {
return pageMeta(pathname).category;
}
export function pageMeta(pathname: string): PageMeta {
if (pathname === '/') {
return { title: 'Dashboard', category: 'Overview', icon: dashboardItem.icon };
}
if (pathname.startsWith('/ordering/manage')) {
const section = findOrderingSection(pathname);
return section && section.href !== '/ordering/manage'
? `Order Management · ${section.label}`
: 'Order Management';
return {
title: section?.label ?? 'Orders',
category: 'Order Management',
icon: section?.icon ?? orderingManageGroup.icon
};
}
if (pathname.startsWith('/ordering')) return 'Ordering';
return clientNavigationItems.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
if (pathname.startsWith('/ordering')) {
return { title: 'Ordering', category: 'Ordering', icon: orderingItem.icon };
}
if (pathname.startsWith('/throughput/add')) {
return { title: 'Add Entry', category: 'Operations', icon: throughputItem.icon };
}
if (pathname.startsWith('/throughput')) {
return { title: throughputItem.label, category: 'Operations', icon: throughputItem.icon };
}
if (pathname.startsWith('/reporting')) {
return { title: reportingItem.label, category: 'Insights', icon: reportingItem.icon };
}
if (pathname.startsWith('/mix-calculator')) {
return { title: mixCalculatorItem.label, category: 'Operations', icon: mixCalculatorItem.icon };
}
if (pathname.startsWith('/product-costing')) {
return { title: productCostingItem.label, category: 'Operations', icon: productCostingItem.icon };
}
if (pathname.startsWith('/editor')) {
return { title: editorItem.label, category: 'Operations', icon: editorItem.icon };
}
if (pathname.startsWith('/ingredients')) {
return { title: ingredientsEditorItem.label, category: 'Operations', icon: ingredientsEditorItem.icon };
}
if (pathname.startsWith('/raw-materials')) {
return { title: 'Raw Materials', category: 'Operations', icon: FlaskConical };
}
if (pathname.startsWith('/products')) {
return { title: 'Products', category: 'Operations', icon: Package };
}
if (pathname.startsWith('/mixes/new')) {
return { title: 'New Mix', category: 'Operations', icon: ClipboardPenLine };
}
if (pathname.startsWith('/mixes')) {
return { title: 'Mix Master', category: 'Operations', icon: Layers };
}
if (pathname.startsWith('/scenarios')) {
return { title: 'Scenarios', category: 'Operations', icon: Layers };
}
if (pathname.startsWith('/client-access')) {
return { title: accessControlItem.label, category: 'Administration', icon: accessControlItem.icon };
}
if (pathname.startsWith('/settings')) {
return { title: 'Settings', category: 'Workspace', icon: Settings };
}
return { title: 'Workspace', category: 'Workspace', icon: LayoutDashboard };
}
export function clientBreadcrumbs(pathname: string, session?: AppSession | null): Crumb[] {
+216
View File
@@ -216,7 +216,223 @@
.manage-shell .modal h2 { margin: 0 0 1rem; }
.manage-shell .modal .actions { justify-content: flex-end; margin-top: 1.15rem; }
/* Console primitives: shared master/detail vocabulary
* Reusable across the management console (products, orders, ) so each page
* reads the same. Everything resolves from design tokens. */
/* Two-pane master/detail: a list on the left, a sticky detail on the right. */
.manage-shell .console-split {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.7fr);
gap: 1rem;
align-items: start;
}
.manage-shell .console-split > .detail {
position: sticky;
top: 1rem;
}
/* Count chip beside a heading. */
.manage-shell .count {
margin-left: 0.3rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* Full-width search field above a list. */
.manage-shell .list-search {
width: 100%;
margin-bottom: 0.7rem;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-input-bg);
color: var(--color-text-primary);
font: inherit;
transition: border-color 140ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 140ms cubic-bezier(0.22, 1, 0.36, 1), background-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
}
.manage-shell .list-search:focus {
outline: none;
border-color: var(--color-brand);
background: var(--color-bg-surface);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
}
/* Inline reveal panel for create forms (replaces create modals). */
.manage-shell .create-panel {
margin-bottom: 0.85rem;
padding: 0.95rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--panel-soft);
}
.manage-shell .create-panel .actions { justify-content: flex-end; margin-top: 0.85rem; }
/* Two-line identity cell: bold name over a muted sub-line. */
.manage-shell .id-cell { display: grid; gap: 0.12rem; min-width: 0; }
.manage-shell .id-name { font-size: 0.88rem; font-weight: 600; color: var(--color-text-primary); }
.manage-shell .id-sub { font-size: 0.74rem; color: var(--color-text-muted); }
/* Detail header: title block left, status + actions right. */
.manage-shell .detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
padding-bottom: 0.95rem;
margin-bottom: 0.3rem;
border-bottom: 1px solid var(--color-divider);
}
.manage-shell .detail-title h2 { margin: 0; font-size: 1.2rem; }
.manage-shell .eyebrow {
margin: 0 0 0.15rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.manage-shell .detail-head-actions { display: inline-flex; align-items: center; gap: 0.6rem; flex-shrink: 0; }
/* Square icon button (close, etc.). */
.manage-shell .icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.3rem;
height: 2.3rem;
flex-shrink: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: background-color 150ms cubic-bezier(0.22, 1, 0.36, 1),
color 150ms cubic-bezier(0.22, 1, 0.36, 1), border-color 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.manage-shell .icon-btn:hover { background: var(--color-surface-hover); color: var(--color-text-primary); }
.manage-shell .icon-btn svg { display: block; }
/* Section sub-header inside a detail panel. */
.manage-shell .section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.65rem;
}
.manage-shell .section-head h3 { margin: 0; }
/* Inline meta chips (PO, fulfilment, dates). */
.manage-shell .meta { display: flex; flex-wrap: wrap; gap: 0.45rem 1.1rem; margin: 0.2rem 0 1rem; }
.manage-shell .meta-item { display: grid; gap: 0.1rem; }
.manage-shell .meta-item span {
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.manage-shell .meta-item strong { font-size: 0.84rem; font-weight: 600; color: var(--color-text-primary); }
/* Centered empty state for an unselected detail pane. */
.manage-shell .empty-detail {
display: grid;
place-items: center;
text-align: center;
gap: 0.5rem;
padding: 3rem 1.5rem;
}
.manage-shell .empty-detail h2 { margin: 0; }
.manage-shell .empty-detail .muted { max-width: 28rem; margin: 0; }
.manage-shell .empty-icon {
display: grid;
place-items: center;
width: 3.25rem;
height: 3.25rem;
border-radius: 0.95rem;
background: var(--color-brand-tint);
color: var(--color-brand);
}
/* Status pill that reads as Active / Disabled from a boolean. */
.manage-shell .pill.muted-pill {
color: var(--color-text-muted);
background: color-mix(in srgb, var(--panel-soft) 70%, var(--color-bg-surface));
}
/* Workspace shell: a pinned header + tabs over a scrolling body
* Gives a detail pane the feel of an anchored management surface. The head
* (title, status, tabs, primary actions) stays put while the body scrolls,
* capped to the viewport so the list beside it is always reachable. */
.manage-shell .workspace {
padding: 0;
display: flex;
flex-direction: column;
max-height: calc(100vh - 2rem);
overflow: hidden;
}
.manage-shell .workspace-head {
flex-shrink: 0;
padding: var(--space-card) var(--space-card) 0;
border-bottom: 1px solid var(--color-divider);
}
.manage-shell .workspace-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--space-card);
}
.manage-shell .workspace-tabs {
display: flex;
gap: 0.1rem;
margin-top: 0.9rem;
overflow-x: auto;
scrollbar-width: none;
}
.manage-shell .workspace-tabs::-webkit-scrollbar { display: none; }
.manage-shell .workspace-tab {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.7rem;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--color-text-muted);
font: inherit;
font-size: 0.83rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: color 140ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
}
.manage-shell .workspace-tab:hover { color: var(--color-text-primary); }
.manage-shell .workspace-tab[aria-selected='true'] {
color: var(--color-brand);
border-bottom-color: var(--color-brand);
}
.manage-shell .workspace-tab svg { display: block; }
.manage-shell .workspace-tab .tab-count {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.manage-shell .workspace-tab[aria-selected='true'] .tab-count { color: var(--color-brand); }
/* Vertical rhythm for stacked sections inside a workspace body. */
.manage-shell .ws-section + .ws-section { margin-top: 1.4rem; }
@media (max-width: 1000px) {
.manage-shell .split,
.manage-shell .console-split,
.manage-shell .form-grid { grid-template-columns: 1fr; }
.manage-shell .console-split > .detail { position: static; }
.manage-shell .workspace { max-height: none; }
}
+35
View File
@@ -303,6 +303,12 @@ export type EditorProductUpdateInput = {
notes?: string | null;
};
export type EditorMixCreateInput = {
client_name: string;
name: string;
notes?: string | null;
};
export type EditorMixUpdateInput = {
client_name?: string;
name?: string;
@@ -338,6 +344,35 @@ export type EditorMixFormula = {
total_kg: number;
};
export type EditorResolvedMixIngredient = {
raw_material_id: number;
raw_material_name: string;
quantity_kg: number;
mix_percentage: number;
unit: string;
notes: string | null;
};
// A mix formula resolved exactly as the Mix Calculator reads it. `source` is
// 'product' when it comes from a representative product's own formula, or 'mix'
// when it comes from the shared mix master fallback.
export type EditorResolvedMixFormula = {
id: number;
tenant_id: string;
client_name: string;
name: string;
source: 'product' | 'mix';
product_id: number | null;
ingredients: EditorResolvedMixIngredient[];
total_kg: number;
};
export type EditorMixFormulaRowInput = {
raw_material_id: number;
quantity_kg: number;
notes?: string | null;
};
export type EditorProductIngredient = {
id: number;
raw_material_id: number;
@@ -437,7 +437,6 @@
</section>
<style>
h2,
h3,
h4,
p,
@@ -453,21 +452,12 @@
text-transform: uppercase;
}
.page-intro,
.metric-row,
.workspace-grid,
.preview-grid {
margin-bottom: 1.25rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 18ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.card-toolbar p,
.client-row span,
+297 -56
View File
@@ -5,13 +5,13 @@
import SortHeader from '$lib/table/SortHeader.svelte';
import { TableController } from '$lib/table/table.svelte';
import type {
EditorMixFormula,
EditorMixIngredient,
EditorResolvedMixFormula,
EditorResolvedMixIngredient,
EditorMixRow,
EditorMixUpdateInput,
RawMaterial
} from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
let { data } = $props();
@@ -22,9 +22,10 @@
};
type DraftIngredient = {
id: number | null;
raw_material_id: number | null;
quantity_kg: number;
// Percentage is an entry aid; kilograms remain the canonical saved value.
percentage: number;
notes: string;
};
@@ -34,8 +35,17 @@
let visibilityFilter = $state<'all' | 'visible' | 'hidden'>('visible');
let savingKey = $state<string | null>(null);
let expandedMixId = $state<number | null>(null);
let activeFormula = $state<EditorMixFormula | null>(null);
let activeFormula = $state<EditorResolvedMixFormula | null>(null);
let ingredientDrafts = $state<DraftIngredient[]>([]);
// The reference total used to convert between % and kg. Editing a kg cell
// redefines it (kg is the source of truth); editing the Total mix field
// rescales every row's kg from its %.
let totalReference = $state(0);
// Inline "create new mix" form state.
let creatingMix = $state(false);
let newMixClient = $state('');
let newMixName = $state('');
function toEditableRow(row: EditorMixRow): EditableRow {
return {
@@ -51,29 +61,67 @@
}
});
function ingredientToDraft(ingredient: EditorMixIngredient): DraftIngredient {
// Round to avoid binary-float noise in the inputs (e.g. 59.60000000001).
function round4(value: number) {
return Math.round(value * 1e4) / 1e4;
}
function ingredientToDraft(ingredient: EditorResolvedMixIngredient): DraftIngredient {
return {
id: ingredient.id,
raw_material_id: ingredient.raw_material_id,
quantity_kg: ingredient.quantity_kg,
percentage: ingredient.mix_percentage,
notes: ingredient.notes ?? ''
};
}
function emptyIngredient(): DraftIngredient {
return {
id: null,
raw_material_id: (data.rawMaterials as RawMaterial[])[0]?.id ?? null,
quantity_kg: 0,
percentage: 0,
notes: ''
};
}
function loadIngredientDrafts(formula: EditorMixFormula) {
function loadIngredientDrafts(formula: EditorResolvedMixFormula) {
activeFormula = formula;
totalReference = formula.total_kg || 0;
ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()];
}
// kg overrides %: recompute the reference total from the kg column, then
// re-derive every row's percentage so they always sum to 100.
function applyKgEdit() {
const total = ingredientDrafts.reduce((sum, row) => sum + Number(row.quantity_kg || 0), 0);
totalReference = round4(total);
ingredientDrafts = ingredientDrafts.map((row) => ({
...row,
percentage: total > 0 ? round4((Number(row.quantity_kg || 0) / total) * 100) : 0
}));
}
// % overrides kg: convert this row's percentage to kg against the locked
// reference total. Other rows are untouched, so the percentage total will
// read off 100 until the rest are adjusted (the save guard enforces 100%).
function applyPercentEdit(index: number) {
const total = totalReference;
ingredientDrafts = ingredientDrafts.map((row, rowIndex) =>
rowIndex === index
? { ...row, quantity_kg: total > 0 ? round4((Number(row.percentage || 0) / 100) * total) : 0 }
: row
);
}
// Editing the Total mix (kg) rescales every row's kg from its current %.
function applyTotalEdit() {
const total = Number(totalReference || 0);
ingredientDrafts = ingredientDrafts.map((row) => ({
...row,
quantity_kg: total > 0 ? round4((Number(row.percentage || 0) / 100) * total) : 0
}));
}
function rowDirty(row: EditableRow) {
return row.draft_mix_name !== row.name || row.draft_visible !== row.visible;
}
@@ -104,6 +152,61 @@
rows = rows.map((candidate) => (candidate.id === row.id ? toEditableRow(candidate) : candidate));
}
function openCreateMix() {
creatingMix = true;
// Prefill the client from the active filter so creating several mixes for
// one client is quick.
newMixClient = clientFilter !== 'all' ? clientFilter : '';
newMixName = '';
}
function cancelCreateMix() {
creatingMix = false;
newMixClient = '';
newMixName = '';
}
async function createMix() {
if (!newMixClient.trim()) {
toast.error('Client name is required.');
return;
}
if (!newMixName.trim()) {
toast.error('Mix name is required.');
return;
}
savingKey = 'mix-create';
try {
const created = await api.createEditorMix({
client_name: newMixClient.trim(),
name: newMixName.trim()
});
rows = [toEditableRow(created), ...rows];
cancelCreateMix();
// A new mix is Inactive (no products yet) and may sit under any client, so
// clear filters and sorting to guarantee it surfaces at the top, then open
// its ingredient panel so the recipe can be built straight away.
query = '';
clientFilter = 'all';
visibilityFilter = 'all';
table.sortKey = null;
table.reset();
toast.success('Mix created');
const newRow = rows.find((row) => row.id === created.id);
if (newRow) await toggleIngredients(newRow);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to create mix');
} finally {
savingKey = null;
}
}
async function toggleIngredients(row: EditableRow) {
if (expandedMixId === row.id) {
expandedMixId = null;
@@ -116,7 +219,7 @@
savingKey = `mix-load:${row.id}`;
try {
loadIngredientDrafts(await api.editorMixFormula(row.id));
loadIngredientDrafts(await api.editorMixResolvedFormula(row.id));
} catch (error) {
expandedMixId = null;
toast.error(error instanceof Error ? error.message : 'Unable to load ingredients');
@@ -132,6 +235,7 @@
function removeIngredient(index: number) {
ingredientDrafts = ingredientDrafts.filter((_, rowIndex) => rowIndex !== index);
if (!ingredientDrafts.length) ingredientDrafts = [emptyIngredient()];
applyKgEdit();
}
function ingredientWarnings() {
@@ -148,6 +252,11 @@
if (Number(row.quantity_kg) <= 0) return [`Ingredient row ${index + 1} needs a quantity greater than zero.`];
}
// Percentages must add up to 100 before a change can be saved.
if (Math.abs(percentTotal - 100) > 0.1) {
return [`Percentages must total 100% (currently ${percentTotal.toFixed(2)}%).`];
}
return [];
}
@@ -162,42 +271,13 @@
savingKey = `mix-save:${activeFormula.id}`;
try {
const cleanRows = ingredientDrafts.map((row) => ({
id: row.id,
const payloadRows = ingredientDrafts.map((row) => ({
raw_material_id: row.raw_material_id as number,
quantity_kg: Number(row.quantity_kg),
notes: row.notes.trim() || null
}));
const originalById = new Map(activeFormula.ingredients.map((ingredient) => [ingredient.id, ingredient]));
const keptIds = new Set(cleanRows.filter((row) => row.id !== null).map((row) => row.id as number));
for (const ingredient of activeFormula.ingredients) {
const draft = cleanRows.find((row) => row.id === ingredient.id);
if (!keptIds.has(ingredient.id) || (draft && draft.raw_material_id !== ingredient.raw_material_id)) {
await api.deleteEditorMixIngredient(activeFormula.id, ingredient.id);
}
}
for (const row of cleanRows) {
const original = row.id === null ? null : originalById.get(row.id);
if (!original || original.raw_material_id !== row.raw_material_id) {
await api.addEditorMixIngredient(activeFormula.id, {
raw_material_id: row.raw_material_id,
quantity_kg: row.quantity_kg,
notes: row.notes
});
continue;
}
if (original.quantity_kg !== row.quantity_kg || (original.notes ?? null) !== row.notes) {
await api.updateEditorMixIngredient(activeFormula.id, original.id, {
quantity_kg: row.quantity_kg,
notes: row.notes
});
}
}
loadIngredientDrafts(await api.editorMixFormula(activeFormula.id));
loadIngredientDrafts(await api.replaceEditorMixFormula(activeFormula.id, payloadRows));
toast.success('Ingredients saved');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to save ingredients');
@@ -262,6 +342,10 @@
const ingredientTotalKg = $derived(
ingredientDrafts.reduce((sum, ingredient) => sum + Number(ingredient.quantity_kg || 0), 0)
);
const percentTotal = $derived(
ingredientDrafts.reduce((sum, ingredient) => sum + Number(ingredient.percentage || 0), 0)
);
const percentBalanced = $derived(Math.abs(percentTotal - 100) <= 0.1);
// Jump back to the first page whenever the filtered set changes.
$effect(() => {
@@ -340,18 +424,59 @@
</span>
</div>
<dl class="facts">
<div class="fact">
<dt>Mixes</dt>
<dd>{visibleRows.length}</dd>
</div>
<div class="fact">
<dt>Unsaved</dt>
<dd>{dirtyCount}</dd>
</div>
</dl>
<div class="status-actions">
<dl class="facts">
<div class="fact">
<dt>Mixes</dt>
<dd>{visibleRows.length}</dd>
</div>
<div class="fact">
<dt>Unsaved</dt>
<dd>{dirtyCount}</dd>
</div>
</dl>
<button class="apply-button new-mix-button" type="button" onclick={openCreateMix} disabled={creatingMix}>
<Plus size={16} strokeWidth={2.4} />
New mix
</button>
</div>
</div>
{#if creatingMix}
<form class="create-panel" transition:fade={{ duration: 120 }} onsubmit={(event) => { event.preventDefault(); createMix(); }}>
<div class="create-head">
<strong>New mix</strong>
<span>Create a mix, then build its formula below.</span>
</div>
<div class="create-fields">
<label>
<span>Client</span>
<input bind:value={newMixClient} list="editor-client-options" placeholder="Client name" />
</label>
<label>
<span>Mix name</span>
<!-- svelte-ignore a11y_autofocus -->
<input bind:value={newMixName} placeholder="Mix name" autofocus />
</label>
</div>
<datalist id="editor-client-options">
{#each clientOptions as client}
<option value={client}></option>
{/each}
</datalist>
<div class="create-actions">
<button class="clear-button" type="button" onclick={cancelCreateMix}>Cancel</button>
<button class="apply-button" type="submit" disabled={savingKey === 'mix-create'}>
{savingKey === 'mix-create' ? 'Creating...' : 'Create mix'}
</button>
</div>
</form>
{/if}
<div class="pagination-bar" aria-label="Mix table pagination">
<span>{table.pageStart}-{table.pageEnd} of {table.total}</span>
<label class="page-size">
@@ -420,17 +545,30 @@
<div class="ingredient-panel" transition:fade={{ duration: 120 }}>
<div class="ingredient-head">
<div>
<span>Ingredients</span>
<span>Ingredients{#if activeFormula} · {activeFormula.source === 'product' ? 'product formula' : 'mix master'}{/if}</span>
<strong>{activeFormula?.client_name ?? row.client_name} / {activeFormula?.name ?? row.name}</strong>
</div>
<div class="ingredient-summary">
<span>{ingredientDrafts.length} rows</span>
<span>{ingredientTotalKg.toFixed(2)} kg</span>
<label class="total-field">
<span>Total mix (kg)</span>
<input
bind:value={totalReference}
onchange={applyTotalEdit}
type="number"
min="0"
step="0.0001"
aria-label="Total mix kilograms"
/>
</label>
<span class="percent-chip" class:off={!percentBalanced} aria-live="polite">
{percentTotal.toFixed(2)}%
</span>
</div>
</div>
<div class="ingredient-grid">
<span class="grid-label">Raw material</span>
<span class="grid-label">%</span>
<span class="grid-label">kg</span>
<span class="grid-label">Notes</span>
<span class="grid-label">Remove</span>
@@ -441,8 +579,17 @@
<option value={material.id}>{material.name}</option>
{/each}
</select>
<input
bind:value={ingredient.percentage}
onchange={() => applyPercentEdit(index)}
type="number"
min="0"
step="0.0001"
aria-label={`Percentage for ${rawMaterialName(ingredient.raw_material_id)}`}
/>
<input
bind:value={ingredient.quantity_kg}
onchange={applyKgEdit}
type="number"
min="0"
step="0.0001"
@@ -454,8 +601,9 @@
</div>
<div class="ingredient-footer">
<span class="footer-total">Total {ingredientTotalKg.toFixed(2)} kg</span>
<button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button>
<button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}`} onclick={saveIngredients}>
<button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}` || !percentBalanced} onclick={saveIngredients}>
{savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
</button>
</div>
@@ -602,12 +750,69 @@
font-size: 0.88rem;
}
.status-actions {
display: flex;
align-items: center;
gap: 1.35rem;
flex-wrap: wrap;
}
.new-mix-button {
flex-shrink: 0;
}
.facts {
display: flex;
gap: 1.35rem;
margin: 0;
}
.create-panel {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1rem 1.1rem;
background: var(--color-bg-surface);
border: 1px solid color-mix(in srgb, var(--color-brand) 32%, var(--color-border));
border-radius: 0.9rem;
}
.create-head {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.create-head strong {
color: var(--color-text-primary);
font-size: 1.02rem;
font-weight: 700;
}
.create-head span {
color: var(--color-text-secondary);
font-size: 0.86rem;
}
.create-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
gap: 0.75rem;
}
.create-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.6rem;
}
@media (max-width: 760px) {
.create-fields {
grid-template-columns: 1fr;
}
}
.fact {
display: flex;
flex-direction: column;
@@ -1004,11 +1209,47 @@
.ingredient-grid {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(110px, 0.25fr) minmax(180px, 0.7fr) auto;
grid-template-columns: minmax(220px, 1fr) minmax(88px, 0.22fr) minmax(110px, 0.25fr) minmax(160px, 0.6fr) auto;
gap: 0.45rem;
align-items: center;
}
.total-field {
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
.total-field input {
width: 7.5rem;
}
.percent-chip {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.7rem;
border: 1px solid color-mix(in srgb, var(--color-success) 40%, var(--color-border));
border-radius: 999px;
background: var(--color-bg-surface);
color: var(--color-success);
font-size: 0.92rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.percent-chip.off {
border-color: color-mix(in srgb, var(--color-error) 45%, var(--color-border));
color: var(--color-error);
}
.footer-total {
margin-right: auto;
color: var(--color-text-secondary);
font-size: 0.9rem;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.remove-button {
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 38%, var(--color-border));
-11
View File
@@ -180,14 +180,6 @@
</script>
<div class="ordering">
<header class="page-head">
<div>
<p class="eyebrow">Ordering Portal</p>
<h1>Order catalogue</h1>
<p class="sub">Your account-specific products and pricing. Prices exclude GST.</p>
</div>
</header>
<div class="layout">
<!-- Catalogue -->
<section class="catalogue surface-card">
@@ -334,12 +326,9 @@
<style>
.ordering { display: grid; gap: 1.25rem; }
h1 { margin: 0.2rem 0; font-size: 1.5rem; }
h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
h3 { margin: 0; font-size: 0.98rem; }
p { margin: 0; }
.eyebrow { color: var(--color-brand, #2f6f4f); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.sub { color: #64776b; font-size: 0.88rem; }
.surface-card { border: 1px solid rgba(34, 54, 45, 0.12); border-radius: 1rem; background: var(--surface, rgba(255,255,255,0.9)); padding: 1.1rem; }
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 22rem; gap: 1.25rem; align-items: start; }
.toolbar { display: grid; gap: 0.6rem; margin-bottom: 1rem; }
@@ -1,29 +1,15 @@
<script lang="ts">
import { page } from '$app/state';
import '$lib/ordering/manage.css';
import { findOrderingSection } from '$lib/navigation/client-navigation';
let { children } = $props();
// The header mirrors the active section: "Order Management" eyebrow above, then
// the current page's name (Orders, Products, …, or a nested page like Xero).
// Reuse the rail's section finder so the title stays in sync with navigation.
const sectionLabel = $derived(findOrderingSection(page.url.pathname)?.label ?? 'Orders');
</script>
<!-- Section navigation lives in the primary left rail (and the mobile drawer),
so the console pages don't repeat it inline. -->
<div class="manage-shell">
<header>
<p class="eyebrow">Order Management</p>
<h1>{sectionLabel}</h1>
</header>
{@render children()}
</div>
<style>
.manage-shell { display: grid; gap: 1rem; }
h1 { margin: 0.15rem 0; font-size: 1.4rem; letter-spacing: -0.02em; }
.eyebrow { color: var(--color-text-muted); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
</style>
+172 -69
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import { ClipboardList, X } from 'lucide-svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import { tooltip } from '$lib/actions/tooltip';
import { money, label, statusTone, ORDER_STATUSES } from '$lib/ordering/format';
import type { Order } from '$lib/types';
@@ -11,6 +14,16 @@
orders = data.orders ?? [];
});
let listQuery = $state('');
const filteredOrders = $derived.by(() => {
const q = listQuery.trim().toLowerCase();
if (!q) return orders;
return orders.filter((o) => {
const ref = (o.order_number ?? `#${o.id}`).toLowerCase();
return ref.includes(q) || (o.customer_name ?? '').toLowerCase().includes(q);
});
});
let selectedOrder = $state<Order | null>(null);
let statusChoice = $state('');
@@ -26,6 +39,9 @@
selectedOrder = null;
statusChoice = '';
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && selectedOrder) closeOrder();
}
async function refreshOrders() {
try {
orders = await api.orderingAdmin.orders();
@@ -45,7 +61,10 @@
async function overrideLine(lineId: number, value: string) {
if (!selectedOrder || value === '') return;
try {
selectedOrder = await api.orderingAdmin.overrideLine(selectedOrder.id, lineId, { unit_price: Number(value), reason: 'Admin override' });
selectedOrder = await api.orderingAdmin.overrideLine(selectedOrder.id, lineId, {
unit_price: Number(value),
reason: 'Admin override'
});
toast.success('Line price overridden.');
await refreshOrders();
} catch (e) {
@@ -75,83 +94,167 @@
}
</script>
<section class="surface-card">
<div class="card-head">
<h2>Orders ({orders.length})</h2>
</div>
{#if !orders.length}
<p class="empty">No submitted orders.</p>
{:else}
<table class="clickable">
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Subtotal</th><th>Xero</th></tr></thead>
<tbody>
{#each orders as o (o.id)}
<tr class:selected={selectedOrder?.id === o.id} onclick={() => openOrder(o)}>
<td>{o.order_number ?? `#${o.id}`}</td>
<td>{o.customer_name}</td>
<td><span class="pill {statusTone(o.status)}">{label(o.status)}</span></td>
<td>{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
<td>{o.xero_status ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<svelte:window onkeydown={handleWindowKeydown} />
{#if selectedOrder}
<div class="modal-backdrop" role="presentation" onclick={closeOrder}>
<div
class="modal wide detail"
role="dialog"
aria-modal="true"
aria-label="Order detail"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') closeOrder(); }}
>
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
<p class="muted">{selectedOrder.customer_name} · {label(selectedOrder.status)} · PO {selectedOrder.purchase_order_number ?? '—'}</p>
<table class="lines">
<thead><tr><th>Product</th><th>Qty</th><th>Unit</th><th>Override</th><th>Total</th></tr></thead>
<div class="console-split">
<!-- ── Left: order queue ─────────────────────────────────────────────────── -->
<section class="surface-card">
<div class="card-head">
<h2>Orders <span class="count">{filteredOrders.length}</span></h2>
</div>
<input class="list-search" type="search" placeholder="Search order or customer" bind:value={listQuery} />
{#if filteredOrders.length}
<table class="clickable">
<thead>
<tr><th>Order</th><th>Status</th><th class="amt">Subtotal</th></tr>
</thead>
<tbody>
{#each selectedOrder.lines as l (l.id)}
<tr>
<td>{l.product_name}</td>
<td>{l.quantity}</td>
<td>{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
{#each filteredOrders as o (o.id)}
<tr
class:selected={selectedOrder?.id === o.id}
tabindex="0"
role="button"
aria-pressed={selectedOrder?.id === o.id}
onclick={() => openOrder(o)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openOrder(o);
}
}}
>
<td>
<input class="ovr" type="number" step="0.01" placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'}
onchange={(e) => overrideLine(l.id, e.currentTarget.value)} />
<div class="id-cell">
<span class="id-name">{o.order_number ?? `#${o.id}`}</span>
<span class="id-sub">{o.customer_name ?? 'Unknown customer'}</span>
</div>
</td>
<td>{money(l.line_total)}</td>
<td><span class="pill {statusTone(o.status)}">{label(o.status)}</span></td>
<td class="amt">{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
</tr>
{/each}
</tbody>
</table>
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
{:else if orders.length}
<p class="empty">No orders match “{listQuery}”.</p>
{:else}
<p class="empty">No submitted orders yet.</p>
{/if}
</section>
<div class="actions">
<select bind:value={statusChoice}>
<option value="">Change status…</option>
{#each ORDER_STATUSES as s}<option value={s}>{label(s)}</option>{/each}
</select>
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
<button class="secondary" onclick={sendToXero}>Send to Xero</button>
<button class="secondary" onclick={reopenOrder}>Reopen</button>
<button class="secondary" onclick={closeOrder}>Close</button>
<!-- ── Right: order detail ───────────────────────────────────────────────── -->
{#if selectedOrder}
<section class="surface-card detail workspace">
<div class="workspace-head">
<div class="detail-head head-row">
<div class="detail-title">
<p class="eyebrow">{selectedOrder.customer_name ?? 'Order'}</p>
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
</div>
<div class="detail-head-actions">
<span class="pill {statusTone(selectedOrder.status)}">{label(selectedOrder.status)}</span>
<button
class="icon-btn"
onclick={closeOrder}
aria-label="Close order"
use:tooltip={{ label: 'Close (Esc)', placement: 'bottom' }}
>
<X size={17} strokeWidth={2} aria-hidden="true" />
</button>
</div>
</div>
<div class="actions order-toolbar">
<select bind:value={statusChoice} aria-label="Change status">
<option value="">Change status…</option>
{#each ORDER_STATUSES as s}<option value={s}>{label(s)}</option>{/each}
</select>
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
<button class="secondary" onclick={sendToXero} use:tooltip={'Create or update the Xero invoice'}>Send to Xero</button>
<button class="secondary" onclick={reopenOrder} use:tooltip={'Return this order to draft for editing'}>Reopen</button>
</div>
</div>
{#if selectedOrder.status_history?.length}
<details class="history">
<summary>Status history ({selectedOrder.status_history.length})</summary>
<ul>
{#each selectedOrder.status_history as h}
<li>{label(h.from_status ?? 'new')}{label(h.to_status)} · {h.actor_name ?? h.actor_type} · {new Date(h.created_at).toLocaleString('en-AU')}</li>
<div class="workspace-body">
<div class="meta">
<div class="meta-item"><span>PO number</span><strong>{selectedOrder.purchase_order_number ?? '—'}</strong></div>
<div class="meta-item"><span>Fulfilment</span><strong>{label(selectedOrder.fulfilment_method)}</strong></div>
<div class="meta-item"><span>Xero</span><strong>{selectedOrder.xero_status ?? 'Not sent'}</strong></div>
</div>
<table class="lines">
<thead>
<tr><th>Product</th><th class="amt">Qty</th><th class="amt">Unit</th><th>Override</th><th class="amt">Total</th></tr>
</thead>
<tbody>
{#each selectedOrder.lines as l (l.id)}
<tr>
<td>{l.product_name}</td>
<td class="amt">{l.quantity}</td>
<td class="amt">{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
<td>
<input
class="ovr"
type="number"
step="0.01"
placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'}
onchange={(e) => overrideLine(l.id, e.currentTarget.value)}
/>
</td>
<td class="amt">{money(l.line_total)}</td>
</tr>
{/each}
</ul>
</details>
{/if}
</div>
</div>
{/if}
</tbody>
</table>
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
{#if selectedOrder.status_history?.length}
<details class="history">
<summary>Status history ({selectedOrder.status_history.length})</summary>
<ul>
{#each selectedOrder.status_history as h}
<li>
{label(h.from_status ?? 'new')}{label(h.to_status)} ·
{h.actor_name ?? h.actor_type} ·
{new Date(h.created_at).toLocaleString('en-AU')}
</li>
{/each}
</ul>
</details>
{/if}
</div>
</section>
{:else}
<section class="surface-card detail empty-detail">
<span class="empty-icon" aria-hidden="true"><ClipboardList size={26} strokeWidth={1.7} /></span>
<h2>Select an order</h2>
<p class="muted">Choose an order from the queue to review its lines, adjust pricing, and move it through fulfilment.</p>
</section>
{/if}
</div>
<style>
/* Right-align numeric columns for clean scanning. */
.amt {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.clickable tbody tr:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: -2px;
}
/* The header already carries the divider, so the title row drops its own. */
.head-row {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 0.85rem;
}
.order-toolbar {
margin-bottom: 0.9rem;
}
</style>
@@ -1,9 +1,12 @@
<script lang="ts">
import { tick } from 'svelte';
import { Building2, Plus } from 'lucide-svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import { statusTone } from '$lib/ordering/format';
import type { CustomerVisibilityRow, OrderingCustomer, OrderingCustomerUser } from '$lib/types';
import CustomerWorkspace from '$lib/components/ordering/CustomerWorkspace.svelte';
import type { OrderingCustomer } from '$lib/types';
let { data } = $props();
@@ -12,30 +15,49 @@
customers = data.customers ?? [];
});
// ── Customer list: search + inline create ──────────────────────────────────
let listQuery = $state('');
const filteredCustomers = $derived.by(() => {
const q = listQuery.trim().toLowerCase();
if (!q) return customers;
return customers.filter(
(c) => c.name.toLowerCase().includes(q) || c.client_code.toLowerCase().includes(q)
);
});
let newCustomer = $state({ name: '', client_code: '' });
let showNewCustomer = $state(false);
let newCustomerNameInput: HTMLInputElement | null = $state(null);
function openNewCustomer() {
newCustomer = { name: '', client_code: '' };
showNewCustomer = true;
}
function closeNewCustomer() {
showNewCustomer = false;
function toggleNewCustomer() {
showNewCustomer = !showNewCustomer;
if (showNewCustomer) newCustomer = { name: '', client_code: '' };
}
$effect(() => {
if (showNewCustomer) tick().then(() => newCustomerNameInput?.focus());
});
let selectedCustomer = $state<OrderingCustomer | null>(null);
let custUsers = $state<OrderingCustomerUser[]>([]);
let custVisibility = $state<CustomerVisibilityRow[]>([]);
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
async function refreshCustomers() {
let selectedCustomer = $state<OrderingCustomer | null>(null);
function openCustomer(c: OrderingCustomer) {
selectedCustomer = c;
}
function closeDetail() {
selectedCustomer = null;
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return;
if (showNewCustomer) showNewCustomer = false;
else if (selectedCustomer) closeDetail();
}
async function refreshCustomers(updated?: OrderingCustomer) {
if (updated && selectedCustomer?.id === updated.id) selectedCustomer = updated;
try {
customers = await api.orderingAdmin.customers();
} catch {}
}
async function createCustomer() {
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
try {
@@ -48,137 +70,101 @@
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
}
}
async function openCustomer(c: OrderingCustomer) {
selectedCustomer = c;
try {
[custUsers, custVisibility] = await Promise.all([
api.orderingAdmin.customerUsers(c.id),
api.orderingAdmin.visibility(c.id)
]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load customer.');
}
}
async function toggleCustomerStatus(c: OrderingCustomer) {
try {
const updated = await api.orderingAdmin.updateCustomer(c.id, { status: c.status === 'active' ? 'disabled' : 'active' });
toast.success(`Customer ${updated.status}.`);
await refreshCustomers();
if (selectedCustomer?.id === c.id) selectedCustomer = updated;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function addUser() {
if (!selectedCustomer) return;
if (!newUser.full_name || !newUser.email) return toast.error('Name and email required.');
try {
await api.orderingAdmin.createCustomerUser(selectedCustomer.id, newUser);
toast.success('User invited.');
newUser = { full_name: '', email: '', role: 'buyer' };
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not add user.');
}
}
async function toggleUserStatus(u: OrderingCustomerUser) {
if (!selectedCustomer) return;
try {
const next = u.status === 'suspended' ? 'active' : 'suspended';
await api.orderingAdmin.updateCustomerUser(selectedCustomer.id, u.id, { status: next });
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function toggleVisibility(row: CustomerVisibilityRow) {
if (!selectedCustomer) return;
try {
await api.orderingAdmin.setVisibility(selectedCustomer.id, { product_id: row.product_id, visible: !row.visible });
custVisibility = await api.orderingAdmin.visibility(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
</script>
<section class="surface-card">
<div class="card-head">
<h2>Customers ({customers.length})</h2>
<button class="primary" onclick={openNewCustomer}>New customer</button>
</div>
<table>
<thead><tr><th>Name</th><th>Code</th><th>Users</th><th>Status</th><th></th></tr></thead>
<tbody>
{#each customers as c (c.id)}
<tr class:selected={selectedCustomer?.id === c.id}>
<td><button class="link" onclick={() => openCustomer(c)}>{c.name}</button></td>
<td>{c.client_code}</td>
<td>{c.user_count}</td>
<td><span class="pill {statusTone(c.status)}">{c.status}</span></td>
<td><button class="link" onclick={() => toggleCustomerStatus(c)}>{c.status === 'active' ? 'Disable' : 'Enable'}</button></td>
</tr>
{/each}
</tbody>
</table>
</section>
<svelte:window onkeydown={handleWindowKeydown} />
{#if selectedCustomer}
<section class="surface-card detail">
<h2>{selectedCustomer.name}</h2>
<h3>Users</h3>
<ul class="mini">
{#each custUsers as u (u.id)}
<li>{u.full_name} · {u.email} · {u.role} · {u.status}
<button class="link" onclick={() => toggleUserStatus(u)}>{u.status === 'suspended' ? 'Reactivate' : 'Suspend'}</button>
</li>
{/each}
</ul>
<div class="form-row">
<input placeholder="Full name" bind:value={newUser.full_name} />
<input placeholder="Email" bind:value={newUser.email} />
<select bind:value={newUser.role}>
<option value="owner">Owner</option><option value="buyer">Buyer</option>
<option value="accounts">Accounts</option><option value="viewer">Viewer</option>
</select>
<button class="secondary" onclick={addUser}>Invite</button>
<div class="console-split">
<!-- ── Left: customer roster ─────────────────────────────────────────────── -->
<section class="surface-card">
<div class="card-head">
<h2>Customers <span class="count">{filteredCustomers.length}</span></h2>
<button class="primary" onclick={toggleNewCustomer}>
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
{showNewCustomer ? 'Cancel' : 'New customer'}
</button>
</div>
<h3 class="mt">Product visibility</h3>
<ul class="mini visibility">
{#each custVisibility as row (row.product_id)}
<li>
<label class="check"><input type="checkbox" checked={row.visible} onchange={() => toggleVisibility(row)} /> {row.name}</label>
</li>
{/each}
</ul>
{#if showNewCustomer}
<div class="create-panel">
<div class="form-grid">
<label class="full">Company name
<input bind:this={newCustomerNameInput} bind:value={newCustomer.name} />
</label>
<label class="full">Client code
<input placeholder="e.g. ACME" bind:value={newCustomer.client_code} />
</label>
</div>
<div class="actions">
<button class="secondary" onclick={toggleNewCustomer}>Cancel</button>
<button class="primary" onclick={createCustomer}>Create customer</button>
</div>
</div>
{/if}
<p class="muted mt">Manage discounts and per-product pricing for this customer on the <a href="/ordering/manage/pricing">Pricing</a> page.</p>
<input class="list-search" type="search" placeholder="Search name or code" bind:value={listQuery} />
{#if filteredCustomers.length}
<table class="clickable">
<thead>
<tr><th>Customer</th><th class="amt">Users</th><th>Status</th></tr>
</thead>
<tbody>
{#each filteredCustomers as c (c.id)}
<tr
class:selected={selectedCustomer?.id === c.id}
tabindex="0"
role="button"
aria-pressed={selectedCustomer?.id === c.id}
onclick={() => openCustomer(c)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openCustomer(c);
}
}}
>
<td>
<div class="id-cell">
<span class="id-name">{c.name}</span>
<span class="id-sub">{c.client_code}{c.discount_percent ? ` · ${c.discount_percent}% off` : ''}</span>
</div>
</td>
<td class="amt">{c.user_count}</td>
<td><span class="pill {statusTone(c.status)}">{c.status}</span></td>
</tr>
{/each}
</tbody>
</table>
{:else if customers.length}
<p class="empty">No customers match “{listQuery}”.</p>
{:else}
<p class="empty">No customers yet. Create one to start managing access.</p>
{/if}
</section>
{/if}
{#if showNewCustomer}
<div class="modal-backdrop" role="presentation" onclick={closeNewCustomer}>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label="New customer"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') closeNewCustomer(); }}
>
<h2>New customer</h2>
<div class="form-grid">
<label class="full">Company name<input bind:this={newCustomerNameInput} bind:value={newCustomer.name} /></label>
<label class="full">Client code<input placeholder="e.g. ACME" bind:value={newCustomer.client_code} /></label>
</div>
<div class="actions">
<button class="secondary" onclick={closeNewCustomer}>Cancel</button>
<button class="primary" onclick={createCustomer}>Create customer</button>
</div>
</div>
</div>
{/if}
<!-- ── Right: customer workspace ─────────────────────────────────────────── -->
{#if selectedCustomer}
<CustomerWorkspace customer={selectedCustomer} onChanged={refreshCustomers} onClose={closeDetail} />
{:else}
<section class="surface-card detail empty-detail">
<span class="empty-icon" aria-hidden="true"><Building2 size={26} strokeWidth={1.7} /></span>
<h2>Select a customer</h2>
<p class="muted">
Pick a company to open its workspace: details, people, catalogue access, orders, mixes, and history in one place.
</p>
</section>
{/if}
</div>
<style>
.amt { text-align: right; font-variant-numeric: tabular-nums; }
.primary { gap: 0.4rem; }
.primary :global(svg) { display: block; }
.clickable tbody tr:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: -2px;
}
</style>
@@ -1,7 +1,10 @@
<script lang="ts">
import { tick } from 'svelte';
import { Plus } from 'lucide-svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import { tooltip } from '$lib/actions/tooltip';
import { label, PRODUCT_CATEGORIES } from '$lib/ordering/format';
import type { CatalogueProduct } from '$lib/types';
@@ -12,17 +15,35 @@
products = data.products ?? [];
});
const blankProduct = () => ({ name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null as number | null, requires_quote: false, active: true });
let listQuery = $state('');
const filteredProducts = $derived.by(() => {
const q = listQuery.trim().toLowerCase();
if (!q) return products;
return products.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.sku.toLowerCase().includes(q) ||
label(p.category).toLowerCase().includes(q)
);
});
const blankProduct = () => ({
name: '',
sku: '',
category: 'grains',
unit_of_measure: '20kg bag',
min_order_quantity: 1,
base_price: null as number | null,
requires_quote: false,
active: true
});
let newProduct = $state<Record<string, any>>(blankProduct());
let showNewProduct = $state(false);
let newProductNameInput: HTMLInputElement | null = $state(null);
function openNewProduct() {
newProduct = blankProduct();
showNewProduct = true;
}
function closeNewProduct() {
showNewProduct = false;
function toggleNewProduct() {
showNewProduct = !showNewProduct;
if (showNewProduct) newProduct = blankProduct();
}
$effect(() => {
if (showNewProduct) tick().then(() => newProductNameInput?.focus());
@@ -36,7 +57,11 @@
async function createProduct() {
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
try {
await api.orderingAdmin.createProduct({ ...newProduct, base_price: newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price) });
await api.orderingAdmin.createProduct({
...newProduct,
base_price:
newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price)
});
toast.success('Product created.');
newProduct = blankProduct();
showNewProduct = false;
@@ -66,43 +91,22 @@
<section class="surface-card">
<div class="card-head">
<h2>Catalogue ({products.length})</h2>
<button class="primary" onclick={openNewProduct}>New product</button>
<h2>Catalogue <span class="count">{filteredProducts.length}</span></h2>
<button class="primary" onclick={toggleNewProduct}>
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
{showNewProduct ? 'Cancel' : 'New product'}
</button>
</div>
<table>
<thead><tr><th>Name</th><th>SKU</th><th>Category</th><th>Base price</th><th>Active</th><th></th></tr></thead>
<tbody>
{#each products as p (p.id)}
<tr>
<td>{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</td>
<td>{p.sku}</td>
<td>{label(p.category)}</td>
<td><input class="inline" type="number" step="0.01" value={p.base_price ?? ''} onchange={(e) => saveProductPrice(p, e.currentTarget.value)} /></td>
<td>{p.active ? 'Yes' : 'No'}</td>
<td><button class="link" onclick={() => toggleProductActive(p)}>{p.active ? 'Disable' : 'Enable'}</button></td>
</tr>
{/each}
</tbody>
</table>
</section>
{#if showNewProduct}
<div class="modal-backdrop" role="presentation" onclick={closeNewProduct}>
<div
class="modal wide"
role="dialog"
aria-modal="true"
aria-label="New product"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') closeNewProduct(); }}
>
<h2>New product</h2>
{#if showNewProduct}
<div class="create-panel">
<div class="form-grid">
<label>Name<input bind:this={newProductNameInput} bind:value={newProduct.name} /></label>
<label>SKU<input bind:value={newProduct.sku} /></label>
<label>Category
<select bind:value={newProduct.category}>{#each PRODUCT_CATEGORIES as c}<option value={c}>{label(c)}</option>{/each}</select>
<select bind:value={newProduct.category}>
{#each PRODUCT_CATEGORIES as c}<option value={c}>{label(c)}</option>{/each}
</select>
</label>
<label>Unit of measure<input bind:value={newProduct.unit_of_measure} /></label>
<label>Min order qty<input type="number" bind:value={newProduct.min_order_quantity} /></label>
@@ -110,9 +114,97 @@
<label class="check full"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label>
</div>
<div class="actions">
<button class="secondary" onclick={closeNewProduct}>Cancel</button>
<button class="secondary" onclick={toggleNewProduct}>Cancel</button>
<button class="primary" onclick={createProduct}>Create product</button>
</div>
</div>
</div>
{/if}
{/if}
<input class="list-search" type="search" placeholder="Search name, SKU, or category" bind:value={listQuery} />
{#if filteredProducts.length}
<table>
<thead>
<tr><th>Product</th><th>Category</th><th>Base price</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{#each filteredProducts as p (p.id)}
<tr class:dimmed={!p.active}>
<td>
<div class="id-cell">
<span class="id-name">{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</span>
<span class="id-sub">{p.sku} · {p.unit_of_measure}</span>
</div>
</td>
<td>{label(p.category)}</td>
<td>
{#if p.requires_quote}
<span class="by-quote">By quote</span>
{:else}
<span class="price-field">
<span class="price-prefix">$</span>
<input
class="inline price-input"
type="number"
step="0.01"
value={p.base_price ?? ''}
placeholder="0.00"
onchange={(e) => saveProductPrice(p, e.currentTarget.value)}
/>
</span>
{/if}
</td>
<td><span class="pill {p.active ? 'pos' : 'muted-pill'}">{p.active ? 'Active' : 'Disabled'}</span></td>
<td class="row-action">
<button
class="link"
onclick={() => toggleProductActive(p)}
use:tooltip={p.active ? 'Hide from all customer catalogues' : 'Make orderable again'}
>
{p.active ? 'Disable' : 'Enable'}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{:else if products.length}
<p class="empty">No products match “{listQuery}”.</p>
{:else}
<p class="empty">No products yet. Create one to build the catalogue.</p>
{/if}
</section>
<style>
.primary { gap: 0.4rem; }
.primary :global(svg) { display: block; }
/* Inactive rows read as muted without disappearing. */
tr.dimmed .id-name,
tr.dimmed td { color: var(--color-text-muted); }
.by-quote {
font-size: 0.78rem;
color: var(--color-text-muted);
font-style: italic;
}
.price-field {
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
.price-prefix {
color: var(--color-text-muted);
font-size: 0.82rem;
}
.price-input {
width: 5.5rem;
font-variant-numeric: tabular-nums;
}
.row-action {
text-align: right;
white-space: nowrap;
}
</style>
@@ -262,19 +262,12 @@
</script>
<section class="costing-shell">
<header class="page-head">
<div>
<span class="eyebrow">Alpha</span>
<h2>Product Costing</h2>
<p>Check prices, fix warnings, and update costing settings.</p>
</div>
<div class="head-actions">
<button class="primary-button" disabled={recalculating} type="button" onclick={recalculateAll}>
<span class:spin={recalculating} aria-hidden="true"><RefreshCcw size={17} /></span>
{recalculating ? 'Updating' : 'Update Prices'}
</button>
</div>
</header>
<div class="head-actions">
<button class="primary-button" disabled={recalculating} type="button" onclick={recalculateAll}>
<span class:spin={recalculating} aria-hidden="true"><RefreshCcw size={17} /></span>
{recalculating ? 'Updating' : 'Update Prices'}
</button>
</div>
<section class="health-strip" aria-label="Product costing health summary">
<article class="health-card">
@@ -715,7 +708,6 @@
animation: spin 900ms linear infinite;
}
h2,
h3,
p {
margin: 0;
@@ -763,7 +755,6 @@
--costing-warn-row: var(--color-warning);
}
.page-head,
.health-strip,
.workspace-grid,
.section-toolbar,
@@ -776,13 +767,6 @@
gap: 1rem;
}
.page-head {
align-items: flex-end;
justify-content: space-between;
padding: 0.35rem 0 0.15rem;
}
.page-head p,
.section-toolbar p,
.block-heading span,
small,
@@ -795,22 +779,6 @@
color: var(--costing-muted);
}
.eyebrow {
display: inline-flex;
margin-bottom: 0.12rem;
color: var(--green-deep);
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-head h2 {
font-size: 1.85rem;
font-weight: 750;
letter-spacing: 0;
}
.head-actions {
align-items: center;
flex-wrap: wrap;
@@ -162,7 +162,6 @@
<section class="add-entry">
<header class="page-header">
<a class="back-link" href="/throughput"><ArrowLeft size={16} /> Back to log</a>
<h1>Add Throughput Entry</h1>
</header>
{#if successMessage}
@@ -275,10 +274,6 @@
flex-direction: column;
gap: 0.4rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
}
.back-link {
display: inline-flex;
align-items: center;
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB