v0.1.27
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:
@@ -33,5 +33,12 @@ LOGIN_RATE_LIMIT_ATTEMPTS=8
|
|||||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
||||||
DOCS_ENABLED=false
|
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_HISTORY=false
|
||||||
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
## Repository operations
|
## 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 300–500 lines, split components.
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
Current app dependency entry points:
|
Current app dependency entry points:
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from app.schemas.editor import (
|
|||||||
EditorIngredientRow,
|
EditorIngredientRow,
|
||||||
EditorIngredientUpdate,
|
EditorIngredientUpdate,
|
||||||
EditorMixFormulaRead,
|
EditorMixFormulaRead,
|
||||||
|
EditorMixCreate,
|
||||||
|
EditorMixFormulaReplace,
|
||||||
EditorMixIngredientCreate,
|
EditorMixIngredientCreate,
|
||||||
EditorMixIngredientUpdate,
|
EditorMixIngredientUpdate,
|
||||||
EditorMixRow,
|
EditorMixRow,
|
||||||
@@ -22,9 +24,11 @@ from app.schemas.editor import (
|
|||||||
EditorProductIngredientUpdate,
|
EditorProductIngredientUpdate,
|
||||||
EditorProductRow,
|
EditorProductRow,
|
||||||
EditorProductUpdate,
|
EditorProductUpdate,
|
||||||
|
EditorResolvedMixFormula,
|
||||||
)
|
)
|
||||||
from app.services.client_access_service import has_access_level
|
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.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"])
|
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)
|
@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
|
||||||
def update_editor_mix(
|
def update_editor_mix(
|
||||||
mix_id: int,
|
mix_id: int,
|
||||||
@@ -377,6 +400,94 @@ def delete_editor_mix_ingredient(
|
|||||||
return _serialize_mix_formula(mix)
|
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)
|
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
|
||||||
def get_editor_product_ingredients(
|
def get_editor_product_ingredients(
|
||||||
product_id: int,
|
product_id: int,
|
||||||
|
|||||||
@@ -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()]
|
||||||
@@ -58,6 +58,12 @@ class Settings:
|
|||||||
login_rate_limit_window_seconds: int
|
login_rate_limit_window_seconds: int
|
||||||
trusted_hosts: tuple[str, ...]
|
trusted_hosts: tuple[str, ...]
|
||||||
docs_enabled: bool
|
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
|
@classmethod
|
||||||
def from_env(cls) -> "Settings":
|
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")),
|
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")),
|
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"),
|
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()
|
settings._validate()
|
||||||
return settings
|
return settings
|
||||||
|
|||||||
@@ -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.powerbi import router as powerbi_router
|
||||||
from app.api.product_costing import router as product_costing_router
|
from app.api.product_costing import router as product_costing_router
|
||||||
from app.api.products import router as products_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.raw_materials import router as raw_materials_router
|
||||||
from app.api.scenarios import router as scenarios_router
|
from app.api.scenarios import router as scenarios_router
|
||||||
from app.api.throughput import router as throughput_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_router)
|
||||||
app.include_router(ordering_admin_router)
|
app.include_router(ordering_admin_router)
|
||||||
app.include_router(powerbi_router)
|
app.include_router(powerbi_router)
|
||||||
|
app.include_router(public_v1_router)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
@@ -270,6 +272,11 @@ async def enforce_request_limits_and_csrf(request: Request, call_next):
|
|||||||
"script-src 'self'; "
|
"script-src 'self'; "
|
||||||
"font-src 'self' data:; "
|
"font-src 'self' data:; "
|
||||||
"connect-src 'self'; "
|
"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'; "
|
"frame-ancestors 'self'; "
|
||||||
"base-uri 'self'; "
|
"base-uri 'self'; "
|
||||||
"form-action 'self'"
|
"form-action 'self'"
|
||||||
@@ -315,6 +322,7 @@ def root():
|
|||||||
"scenarios": "/api/scenarios",
|
"scenarios": "/api/scenarios",
|
||||||
"operations_throughput": "/api/throughput",
|
"operations_throughput": "/api/throughput",
|
||||||
"client_access": "/api/client-access",
|
"client_access": "/api/client-access",
|
||||||
|
"powerbi_throughput": "/api/v1/throughput",
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ class EditorProductUpdate(BaseModel):
|
|||||||
notes: str | None = Field(default=None, max_length=2000)
|
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):
|
class EditorMixUpdate(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -71,6 +79,55 @@ class EditorMixFormulaRead(BaseModel):
|
|||||||
total_kg: float
|
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):
|
class EditorMixIngredientCreate(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
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(
|
def calculate_mix_calculator_preview(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -458,6 +458,21 @@ fi
|
|||||||
throw
|
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 ────────────────────────────────────────────────────────────
|
# ── Health check ────────────────────────────────────────────────────────────
|
||||||
Write-Step "Waiting for backend health check ($BackendContainer)"
|
Write-Step "Waiting for backend health check ($BackendContainer)"
|
||||||
$healthScript = @"
|
$healthScript = @"
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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/ {
|
location /_app/immutable/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
@@ -90,6 +92,14 @@ server {
|
|||||||
location / {
|
location / {
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
add_header Pragma "no-cache" 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;
|
expires -1;
|
||||||
proxy_pass http://lean101_clients_frontend;
|
proxy_pass http://lean101_clients_frontend;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ services:
|
|||||||
LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8}
|
LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8}
|
||||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300}
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300}
|
||||||
DOCS_ENABLED: ${DOCS_ENABLED:-false}
|
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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.23",
|
"version": "0.1.26",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.23",
|
"version": "0.1.26",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"lucide-svelte": "^1.0.1"
|
"lucide-svelte": "^1.0.1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.23",
|
"version": "0.1.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import type {
|
|||||||
ClientUserModulePermission,
|
ClientUserModulePermission,
|
||||||
ClientUserUpdateInput,
|
ClientUserUpdateInput,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
EditorMixCreateInput,
|
||||||
EditorMixUpdateInput,
|
EditorMixUpdateInput,
|
||||||
EditorMixRow,
|
EditorMixRow,
|
||||||
EditorMixFormula,
|
EditorMixFormula,
|
||||||
|
EditorResolvedMixFormula,
|
||||||
|
EditorMixFormulaRowInput,
|
||||||
EditorIngredientRow,
|
EditorIngredientRow,
|
||||||
EditorIngredientCreateInput,
|
EditorIngredientCreateInput,
|
||||||
EditorIngredientUpdateInput,
|
EditorIngredientUpdateInput,
|
||||||
@@ -387,6 +390,11 @@ export const api = {
|
|||||||
const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes';
|
const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes';
|
||||||
return cachedFetchJson<EditorMixRow[]>(path, 'client', fetcher);
|
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) =>
|
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
|
||||||
request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
|
request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -394,6 +402,15 @@ export const api = {
|
|||||||
}, 'client'),
|
}, 'client'),
|
||||||
editorMixFormula: (mixId: number) =>
|
editorMixFormula: (mixId: number) =>
|
||||||
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
|
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 }) =>
|
addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
|
||||||
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {
|
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -73,5 +73,14 @@ export function changelogFor(version: string): ChangelogEntry | undefined {
|
|||||||
return changelog.find((entry) => entry.version === version);
|
return changelog.find((entry) => entry.version === version);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The entry for the version the app is currently running, if documented. */
|
/** The most recent changelog entry, regardless of the running version. */
|
||||||
export const currentChangelog: ChangelogEntry | undefined = changelogFor(APP_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;
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import AuthGate from '$lib/components/AuthGate.svelte';
|
import AuthGate from '$lib/components/AuthGate.svelte';
|
||||||
import WorkspaceBootCard from '$lib/components/app-shell/WorkspaceBootCard.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 WorkspaceSignedOutCard from '$lib/components/app-shell/WorkspaceSignedOutCard.svelte';
|
||||||
import WorkspaceTabletNav from '$lib/components/app-shell/WorkspaceTabletNav.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 { PALETTE_RESULT_LIMIT, buildSessionKey, filterSearchItems } from '$lib/components/app-shell/utils';
|
||||||
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
|
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
|
||||||
import ClientTopbar from '$lib/components/navigation/ClientTopbar.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 WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
|
||||||
import { currentChangelog } from '$lib/changelog';
|
import { currentChangelog } from '$lib/changelog';
|
||||||
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
|
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
import {
|
import {
|
||||||
canCreateMixSession as sessionCanCreateMixSession,
|
canCreateMixSession as sessionCanCreateMixSession,
|
||||||
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
|
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
|
||||||
|
canOpenClientAccess as sessionCanOpenClientAccess,
|
||||||
canOpenDashboard as sessionCanOpenDashboard,
|
canOpenDashboard as sessionCanOpenDashboard,
|
||||||
canOpenEditor as sessionCanOpenEditor,
|
canOpenEditor as sessionCanOpenEditor,
|
||||||
canOpenMixCalculator as sessionCanOpenMixCalculator,
|
canOpenMixCalculator as sessionCanOpenMixCalculator,
|
||||||
@@ -36,9 +37,9 @@
|
|||||||
isWorkspaceRouteAllowed
|
isWorkspaceRouteAllowed
|
||||||
} from '$lib/workspace-access';
|
} from '$lib/workspace-access';
|
||||||
import {
|
import {
|
||||||
|
accessControlItem,
|
||||||
baseSearchItems,
|
baseSearchItems,
|
||||||
buildClientNavEntries,
|
buildClientNavEntries,
|
||||||
clientBreadcrumbs,
|
|
||||||
dashboardItem,
|
dashboardItem,
|
||||||
editorItem,
|
editorItem,
|
||||||
ingredientsEditorItem,
|
ingredientsEditorItem,
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
orderingItem,
|
orderingItem,
|
||||||
orderingManageChildren,
|
orderingManageChildren,
|
||||||
orderingManageGroup,
|
orderingManageGroup,
|
||||||
pageTitle,
|
pageMeta,
|
||||||
productCostingItem,
|
productCostingItem,
|
||||||
reportingItem,
|
reportingItem,
|
||||||
throughputItem,
|
throughputItem,
|
||||||
@@ -63,9 +64,11 @@
|
|||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
const isRootRoute = $derived(page.url.pathname === '/');
|
const isRootRoute = $derived(page.url.pathname === '/');
|
||||||
|
|
||||||
let paletteOpen = $state(false);
|
let searchOpen = $state(false);
|
||||||
let paletteQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let quickMenuOpen = $state(false);
|
let searchFocusRequest = $state(0);
|
||||||
|
let appsFabOpen = $state(false);
|
||||||
|
let sidebarOpen = $state(true);
|
||||||
let userMenuOpen = $state(false);
|
let userMenuOpen = $state(false);
|
||||||
let navOpen = $state(false);
|
let navOpen = $state(false);
|
||||||
let showBottomNav = $state(false);
|
let showBottomNav = $state(false);
|
||||||
@@ -78,6 +81,8 @@
|
|||||||
let seededSearchItems = $state<SearchItem[]>([]);
|
let seededSearchItems = $state<SearchItem[]>([]);
|
||||||
let seededSearchKey = $state<string | null>(null);
|
let seededSearchKey = $state<string | null>(null);
|
||||||
let bootDelayDone = $state(false);
|
let bootDelayDone = $state(false);
|
||||||
|
let sidebarStateReady = $state(false);
|
||||||
|
const SIDEBAR_STORAGE_KEY = 'hsf:shell:sidebar-open';
|
||||||
const appVersion = `v${packageInfo.version}`;
|
const appVersion = `v${packageInfo.version}`;
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
|
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
|
||||||
@@ -92,9 +97,10 @@
|
|||||||
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
|
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
|
||||||
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
|
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
|
||||||
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
|
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
|
||||||
const shellTitle = $derived(routeGuardPending ? 'Loading Workspace' : pageTitle(page.url.pathname));
|
const shellPageMeta = $derived(
|
||||||
const shellBreadcrumbs = $derived(
|
routeGuardPending
|
||||||
routeGuardPending ? clientBreadcrumbs(workspaceHomeHref, $clientSession) : clientBreadcrumbs(page.url.pathname, $clientSession)
|
? { title: 'Loading Workspace', category: 'Workspace', icon: dashboardItem.icon }
|
||||||
|
: pageMeta(page.url.pathname)
|
||||||
);
|
);
|
||||||
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
|
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
|
||||||
const visibleWorkingDocumentItems = $derived(
|
const visibleWorkingDocumentItems = $derived(
|
||||||
@@ -126,9 +132,11 @@
|
|||||||
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
|
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
|
||||||
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
|
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
|
||||||
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
|
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
|
||||||
// Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the
|
const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null);
|
||||||
// standalone operations/insights modules. Built from the same access-filtered
|
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (costing
|
||||||
// items, so a role only ever sees the families it may open.
|
// 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(
|
const navEntries = $derived(
|
||||||
buildClientNavEntries({
|
buildClientNavEntries({
|
||||||
dashboard: visibleDashboardItem,
|
dashboard: visibleDashboardItem,
|
||||||
@@ -149,6 +157,37 @@
|
|||||||
const visibleFooterLinks = $derived([
|
const visibleFooterLinks = $derived([
|
||||||
...(!isOperationsUser ? footerLinks : [])
|
...(!isOperationsUser ? footerLinks : [])
|
||||||
] as FooterLink[]);
|
] 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(
|
const primaryBottomNavigation = $derived(
|
||||||
[
|
[
|
||||||
...(visibleDashboardItem ? [visibleDashboardItem] : []),
|
...(visibleDashboardItem ? [visibleDashboardItem] : []),
|
||||||
@@ -176,11 +215,37 @@
|
|||||||
);
|
);
|
||||||
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
|
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
|
||||||
const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone));
|
const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone));
|
||||||
|
const showDesktopSidebar = $derived(!showBottomNav);
|
||||||
|
|
||||||
function openPalette(query = '') {
|
function restoreSidebarState() {
|
||||||
paletteQuery = query;
|
if (typeof window === 'undefined') {
|
||||||
paletteOpen = true;
|
return true;
|
||||||
quickMenuOpen = false;
|
}
|
||||||
|
|
||||||
|
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;
|
userMenuOpen = false;
|
||||||
navOpen = false;
|
navOpen = false;
|
||||||
}
|
}
|
||||||
@@ -194,13 +259,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runSearchItem(item: SearchItem) {
|
async function runSearchItem(item: SearchItem) {
|
||||||
paletteOpen = false;
|
searchOpen = false;
|
||||||
paletteQuery = '';
|
searchQuery = '';
|
||||||
await goto(item.href);
|
await goto(item.href);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSettings() {
|
async function openSettings() {
|
||||||
quickMenuOpen = false;
|
appsFabOpen = false;
|
||||||
userMenuOpen = false;
|
userMenuOpen = false;
|
||||||
navOpen = false;
|
navOpen = false;
|
||||||
await goto('/settings');
|
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(() => {
|
$effect(() => {
|
||||||
page.url.pathname;
|
page.url.pathname;
|
||||||
quickMenuOpen = false;
|
appsFabOpen = false;
|
||||||
userMenuOpen = false;
|
userMenuOpen = false;
|
||||||
paletteOpen = false;
|
searchOpen = false;
|
||||||
paletteQuery = '';
|
searchQuery = '';
|
||||||
navOpen = false;
|
navOpen = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!sidebarStateReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarOpen;
|
||||||
|
persistSidebarState();
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const hydrated = $sessionHydrated;
|
const hydrated = $sessionHydrated;
|
||||||
const sessionKey = buildSessionKey($clientSession);
|
const sessionKey = buildSessionKey($clientSession);
|
||||||
@@ -271,14 +345,14 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search palette items are seeded lazily — three list endpoints worth of
|
// Search items are seeded lazily — three list endpoints worth of
|
||||||
// data only when the user actually opens the palette, not on every login or
|
// data only when the user actually opens the search, not on every login or
|
||||||
// navigation. Subsequent opens hit the api.ts cache.
|
// navigation. Subsequent opens hit the api.ts cache.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const hydrated = $sessionHydrated;
|
const hydrated = $sessionHydrated;
|
||||||
const session = $clientSession;
|
const session = $clientSession;
|
||||||
const sessionKey = buildSessionKey(session);
|
const sessionKey = buildSessionKey(session);
|
||||||
const shouldSeed = paletteOpen;
|
const shouldSeed = searchOpen;
|
||||||
|
|
||||||
if (!hydrated || !session || !sessionKey) {
|
if (!hydrated || !session || !sessionKey) {
|
||||||
seededSearchItems = [];
|
seededSearchItems = [];
|
||||||
@@ -374,6 +448,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
sidebarOpen = restoreSidebarState();
|
||||||
|
sidebarStateReady = true;
|
||||||
|
|
||||||
const bootTimer = window.setTimeout(() => {
|
const bootTimer = window.setTimeout(() => {
|
||||||
bootDelayDone = true;
|
bootDelayDone = true;
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@@ -390,12 +467,12 @@
|
|||||||
|
|
||||||
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
|
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openPalette();
|
openSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
paletteOpen = false;
|
searchOpen = false;
|
||||||
quickMenuOpen = false;
|
appsFabOpen = false;
|
||||||
userMenuOpen = false;
|
userMenuOpen = false;
|
||||||
navOpen = false;
|
navOpen = false;
|
||||||
}
|
}
|
||||||
@@ -422,7 +499,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{shellTitle} | Hunter Premium Produce</title>
|
<title>{shellPageMeta.title} | Hunter Premium Produce</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !$clientSession}
|
{#if !$clientSession}
|
||||||
@@ -436,9 +513,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="app-shell">
|
<div class:sidebar-collapsed={!sidebarOpen && showDesktopSidebar} class="app-shell">
|
||||||
{#if !showBottomNav}
|
{#if showDesktopSidebar}
|
||||||
<ClientPrimaryRail
|
<ClientPrimaryRail
|
||||||
|
collapsed={!sidebarOpen}
|
||||||
currentPath={shellPathname}
|
currentPath={shellPathname}
|
||||||
entries={navEntries}
|
entries={navEntries}
|
||||||
brandHref={workspaceHomeHref}
|
brandHref={workspaceHomeHref}
|
||||||
@@ -453,18 +531,24 @@
|
|||||||
|
|
||||||
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
|
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
|
||||||
<ClientTopbar
|
<ClientTopbar
|
||||||
breadcrumbs={shellBreadcrumbs}
|
|
||||||
title={shellTitle}
|
|
||||||
sessionHydrated={$sessionHydrated}
|
sessionHydrated={$sessionHydrated}
|
||||||
session={$clientSession}
|
session={$clientSession}
|
||||||
|
showSidebarToggle={!showBottomNav}
|
||||||
|
{sidebarOpen}
|
||||||
{userInitials}
|
{userInitials}
|
||||||
{userMenuOpen}
|
{userMenuOpen}
|
||||||
{canUseWorkspaceSearch}
|
{canUseWorkspaceSearch}
|
||||||
|
bind:searchQuery={searchQuery}
|
||||||
|
bind:searchOpen={searchOpen}
|
||||||
|
searchFocusRequest={searchFocusRequest}
|
||||||
|
filteredSearchItems={searchState.filteredItems}
|
||||||
|
hiddenResultCount={searchState.hiddenResultCount}
|
||||||
{canOpenSettings}
|
{canOpenSettings}
|
||||||
onOpenPalette={() => canUseWorkspaceSearch && openPalette()}
|
onRunSearchItem={runSearchItem}
|
||||||
|
onToggleSidebar={() => (sidebarOpen = !sidebarOpen)}
|
||||||
onToggleUserMenu={() => {
|
onToggleUserMenu={() => {
|
||||||
userMenuOpen = !userMenuOpen;
|
userMenuOpen = !userMenuOpen;
|
||||||
quickMenuOpen = false;
|
appsFabOpen = false;
|
||||||
}}
|
}}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
onSignOut={signOut}
|
onSignOut={signOut}
|
||||||
@@ -472,6 +556,13 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
|
{#if !routeGuardPending}
|
||||||
|
<WorkspacePageHeader
|
||||||
|
category={shellPageMeta.category}
|
||||||
|
title={shellPageMeta.title}
|
||||||
|
icon={shellPageMeta.icon}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<AuthGate
|
<AuthGate
|
||||||
blocked={routeGuardPending}
|
blocked={routeGuardPending}
|
||||||
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
|
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
|
||||||
@@ -487,15 +578,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WorkspaceQuickAccess
|
<WorkspaceAppsFab bind:open={appsFabOpen} items={fabItems} />
|
||||||
bind:quickMenuOpen
|
|
||||||
{canOpenMixMaster}
|
|
||||||
{canCreateMixWorksheet}
|
|
||||||
{canOpenMixCalculator}
|
|
||||||
{canCreateMixSession}
|
|
||||||
{canUseWorkspaceSearch}
|
|
||||||
onOpenPalette={() => openPalette('')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WorkspaceTabletNav
|
<WorkspaceTabletNav
|
||||||
@@ -518,9 +601,7 @@
|
|||||||
{canCreateMixWorksheet}
|
{canCreateMixWorksheet}
|
||||||
{canCreateMixSession}
|
{canCreateMixSession}
|
||||||
{canOpenSettings}
|
{canOpenSettings}
|
||||||
{canUseWorkspaceSearch}
|
|
||||||
pagePath={page.url.pathname}
|
pagePath={page.url.pathname}
|
||||||
onOpenPalette={() => openPalette('')}
|
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
onSignOut={signOut}
|
onSignOut={signOut}
|
||||||
/>
|
/>
|
||||||
@@ -531,16 +612,6 @@
|
|||||||
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
|
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $clientSession && paletteOpen}
|
|
||||||
<WorkspaceSearchPalette
|
|
||||||
bind:query={paletteQuery}
|
|
||||||
filteredSearchItems={paletteState.filteredItems}
|
|
||||||
hiddenResultCount={paletteState.hiddenResultCount}
|
|
||||||
onClose={() => (paletteOpen = false)}
|
|
||||||
onRunSearchItem={runSearchItem}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -549,6 +620,10 @@
|
|||||||
background: var(--color-bg-app);
|
background: var(--color-bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell.sidebar-collapsed {
|
||||||
|
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.signed-out-shell {
|
.signed-out-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|||||||
@@ -206,9 +206,7 @@
|
|||||||
|
|
||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Client Access Control</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>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="status-pill positive">Signed in as {accessManagerLabel}</span>
|
<span class="status-pill positive">Signed in as {accessManagerLabel}</span>
|
||||||
</section>
|
</section>
|
||||||
@@ -587,7 +585,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
h2,
|
|
||||||
h3,
|
h3,
|
||||||
h4,
|
h4,
|
||||||
p,
|
p,
|
||||||
@@ -617,14 +614,12 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro h2 {
|
.page-intro-copy {
|
||||||
margin: 0.35rem 0 0.45rem;
|
max-width: 62ch;
|
||||||
max-width: 22ch;
|
line-height: 1.55;
|
||||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro p:last-child,
|
.page-intro-copy,
|
||||||
.metric-card p,
|
.metric-card p,
|
||||||
.card-toolbar p,
|
.card-toolbar p,
|
||||||
.client-row span,
|
.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">
|
<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 type { FooterLink, NavGroup, NavItem } from '$lib/navigation/client-navigation';
|
||||||
import { matchesRoute } from '$lib/navigation/client-navigation';
|
import { matchesRoute } from '$lib/navigation/client-navigation';
|
||||||
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
showBottomNav,
|
showBottomNav,
|
||||||
@@ -25,9 +24,7 @@
|
|||||||
canCreateMixWorksheet,
|
canCreateMixWorksheet,
|
||||||
canCreateMixSession,
|
canCreateMixSession,
|
||||||
canOpenSettings,
|
canOpenSettings,
|
||||||
canUseWorkspaceSearch,
|
|
||||||
pagePath,
|
pagePath,
|
||||||
onOpenPalette,
|
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onSignOut
|
onSignOut
|
||||||
}: {
|
}: {
|
||||||
@@ -50,9 +47,7 @@
|
|||||||
canCreateMixWorksheet: boolean;
|
canCreateMixWorksheet: boolean;
|
||||||
canCreateMixSession: boolean;
|
canCreateMixSession: boolean;
|
||||||
canOpenSettings: boolean;
|
canOpenSettings: boolean;
|
||||||
canUseWorkspaceSearch: boolean;
|
|
||||||
pagePath: string;
|
pagePath: string;
|
||||||
onOpenPalette: () => void;
|
|
||||||
onOpenSettings: () => void | Promise<void>;
|
onOpenSettings: () => void | Promise<void>;
|
||||||
onSignOut: () => void | Promise<void>;
|
onSignOut: () => void | Promise<void>;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -96,12 +91,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WorkspaceSearchTrigger
|
|
||||||
className="drawer-search"
|
|
||||||
placeholder="Search the workspace..."
|
|
||||||
onClick={() => canUseWorkspaceSearch && onOpenPalette()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="drawer-grid">
|
<div class="drawer-grid">
|
||||||
<nav class="drawer-section" aria-label="All workspace pages">
|
<nav class="drawer-section" aria-label="All workspace pages">
|
||||||
{#if visibleDashboardItem}
|
{#if visibleDashboardItem}
|
||||||
@@ -210,12 +199,6 @@
|
|||||||
<span>Change settings</span>
|
<span>Change settings</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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}>
|
<button type="button" onclick={onSignOut}>
|
||||||
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
|
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
|
||||||
<span>Logout</span>
|
<span>Logout</span>
|
||||||
@@ -404,10 +387,6 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.drawer-search) {
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-grid {
|
.drawer-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
|
|||||||
@@ -299,13 +299,7 @@
|
|||||||
<a href="/">Return to sign-in</a>
|
<a href="/">Return to sign-in</a>
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="page-intro">
|
<section class="page-intro page-actions">
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="intro-actions">
|
<div class="intro-actions">
|
||||||
<a class="secondary-button" href="/mixes">Back to table</a>
|
<a class="secondary-button" href="/mixes">Back to table</a>
|
||||||
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
|
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
|
||||||
@@ -584,15 +578,13 @@
|
|||||||
max-width: 40rem;
|
max-width: 40rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locked-card h2,
|
.locked-card h2 {
|
||||||
.page-intro h2 {
|
|
||||||
margin: 0.3rem 0 0.4rem;
|
margin: 0.3rem 0 0.4rem;
|
||||||
font-size: clamp(1.56rem, 3vw, 2.02rem);
|
font-size: clamp(1.56rem, 3vw, 2.02rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locked-card p:last-of-type,
|
.locked-card p:last-of-type,
|
||||||
.page-intro p:last-child,
|
|
||||||
.metric-card p,
|
.metric-card p,
|
||||||
.summary-card span,
|
.summary-card span,
|
||||||
.factor-list span,
|
.factor-list span,
|
||||||
@@ -619,6 +611,10 @@
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.primary-button,
|
.primary-button,
|
||||||
.secondary-button {
|
.secondary-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { ChevronDown, LogOut, Settings } from 'lucide-svelte';
|
import { ChevronDown, LogOut, Settings } from 'lucide-svelte';
|
||||||
import type { ComponentType } from 'svelte';
|
import type { ComponentType } from 'svelte';
|
||||||
|
import { tooltip } from '$lib/actions/tooltip';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
groupHasActiveChild,
|
groupHasActiveChild,
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
let {
|
let {
|
||||||
brandHref,
|
brandHref,
|
||||||
currentPath,
|
currentPath,
|
||||||
|
collapsed = false,
|
||||||
entries,
|
entries,
|
||||||
footerItems,
|
footerItems,
|
||||||
appVersion,
|
appVersion,
|
||||||
@@ -24,6 +26,7 @@
|
|||||||
}: {
|
}: {
|
||||||
brandHref: string;
|
brandHref: string;
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
|
collapsed?: boolean;
|
||||||
entries: NavEntry[];
|
entries: NavEntry[];
|
||||||
footerItems: FooterLink[];
|
footerItems: FooterLink[];
|
||||||
appVersion: string;
|
appVersion: string;
|
||||||
@@ -97,7 +100,7 @@
|
|||||||
const isOpen = (id: string) => openGroups[id] ?? false;
|
const isOpen = (id: string) => openGroups[id] ?? false;
|
||||||
|
|
||||||
// Accordion: opening a group collapses every other group; closing just shuts
|
// 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) {
|
function toggleGroup(id: string) {
|
||||||
openGroups = isOpen(id) ? {} : { [id]: true };
|
openGroups = isOpen(id) ? {} : { [id]: true };
|
||||||
persistOpenState();
|
persistOpenState();
|
||||||
@@ -131,6 +134,7 @@
|
|||||||
|
|
||||||
let openSubGroups = $state<Record<string, boolean>>(restoreSubState());
|
let openSubGroups = $state<Record<string, boolean>>(restoreSubState());
|
||||||
let lastAutoExpandedSub = $state<string | null>(null);
|
let lastAutoExpandedSub = $state<string | null>(null);
|
||||||
|
let logoutConfirmOpen = $state(false);
|
||||||
|
|
||||||
function persistSubState() {
|
function persistSubState() {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -191,40 +195,81 @@
|
|||||||
openSubGroups = { ...openSubGroups, [key]: true };
|
openSubGroups = { ...openSubGroups, [key]: true };
|
||||||
persistSubState();
|
persistSubState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleLogoutAction() {
|
||||||
|
if (collapsed) {
|
||||||
|
logoutConfirmOpen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSignOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLogoutConfirm() {
|
||||||
|
logoutConfirmOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSignOut() {
|
||||||
|
logoutConfirmOpen = false;
|
||||||
|
onSignOut();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet leafLink(item: NavItem, showIcon: boolean)}
|
{#snippet leafLink(item: NavItem, showIcon: boolean)}
|
||||||
{@const Icon = item.icon}
|
{@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}
|
{#if showIcon && Icon}
|
||||||
<span class="rail-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
<span class="rail-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="rail-text">{item.label}</span>
|
{#if !collapsed}
|
||||||
{#if item.badge}<span class="rail-badge">{item.badge}</span>{/if}
|
<span class="rail-text">{item.label}</span>
|
||||||
|
{#if item.badge}<span class="rail-badge">{item.badge}</span>{/if}
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet actionRow(label: string, Icon: ComponentType, active: boolean, onSelect: () => void)}
|
{#snippet actionRow(label: string, Icon: ComponentType, active: boolean, onSelect: () => void)}
|
||||||
{@const RowIcon = Icon}
|
{@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-icon"><RowIcon size={18} strokeWidth={1.75} /></span>
|
||||||
<span class="rail-text">{label}</span>
|
{#if !collapsed}
|
||||||
|
<span class="rail-text">{label}</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<aside class="sidebar">
|
<aside class:collapsed={collapsed} class="sidebar">
|
||||||
<div class="brand-row">
|
<div class="brand-row">
|
||||||
<a class="brand" href={brandHref}>
|
<a class="brand" href={brandHref}>
|
||||||
<span class="brand-kicker">Hunter App</span>
|
{#if !collapsed}
|
||||||
<span class="brand-wordmark">Hunter Premium Produce</span>
|
<span class="brand-kicker">Hunter App</span>
|
||||||
<span class="brand-subtitle">Operations workspace</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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-body">
|
<div class="sidebar-body">
|
||||||
<div class="rail-scroll">
|
<div class="rail-scroll">
|
||||||
<div class="rail-section-head">
|
<div class="rail-section-head">
|
||||||
<p class="rail-section-label">Modules</p>
|
{#if !collapsed}
|
||||||
|
<p class="rail-section-label">Modules</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="rail-nav" aria-label="Workspace navigation">
|
<nav class="rail-nav" aria-label="Workspace navigation">
|
||||||
@@ -237,11 +282,21 @@
|
|||||||
{@const groupActive = groupHasActiveChild(group, currentPath)}
|
{@const groupActive = groupHasActiveChild(group, currentPath)}
|
||||||
{@const open = isOpen(group.id)}
|
{@const open = isOpen(group.id)}
|
||||||
{@const headerHref = group.href ?? group.children[0]?.href}
|
{@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">
|
<div class="rail-group">
|
||||||
{#if headerHref}
|
{#if headerHref}
|
||||||
<!-- Every family header both navigates and reveals its submenu:
|
<!-- Every family header both navigates and reveals its submenu:
|
||||||
the label goes to the family's landing route (Order Management
|
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
|
list, while the chevron toggles independently. The header never
|
||||||
takes the full active pill — that belongs to the matching child
|
takes the full active pill — that belongs to the matching child
|
||||||
row — it only gets the subtle within-active emphasis when
|
row — it only gets the subtle within-active emphasis when
|
||||||
@@ -330,6 +385,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -344,29 +400,52 @@
|
|||||||
{#if canOpenSettings}
|
{#if canOpenSettings}
|
||||||
{@render actionRow('Settings', Settings, currentPath.startsWith('/settings'), onOpenSettings)}
|
{@render actionRow('Settings', Settings, currentPath.startsWith('/settings'), onOpenSettings)}
|
||||||
{/if}
|
{/if}
|
||||||
{@render actionRow('Logout', LogOut, false, onSignOut)}
|
{@render actionRow('Logout', LogOut, false, handleLogoutAction)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-meta-foot">
|
{#if !collapsed}
|
||||||
<div class="sidebar-meta-top">
|
<div class="sidebar-meta-foot">
|
||||||
<span class="version-pill">
|
<div class="sidebar-meta-top">
|
||||||
<span class="meta-label">Build</span>
|
<span class="version-pill">
|
||||||
<span>{appVersion}</span>
|
<span class="meta-label">Build</span>
|
||||||
</span>
|
<span>{appVersion}</span>
|
||||||
</div>
|
</span>
|
||||||
<div class="sidebar-meta-bottom">
|
</div>
|
||||||
<small>© {currentYear} Hunter Premium Produce</small>
|
<div class="sidebar-meta-bottom">
|
||||||
<div class="powered-by">
|
<small>© {currentYear} Hunter Premium Produce</small>
|
||||||
<span>Powered by</span>
|
<div class="powered-by">
|
||||||
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
|
<span>Powered by</span>
|
||||||
<strong>Lean 101</strong>
|
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
|
||||||
|
<strong>Lean 101</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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>
|
<style>
|
||||||
/* Monochrome rail with a blue selected pill. Colours come from the --sidebar-*
|
/* 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
|
tokens, which are overridden in dark mode (see theme.css) so the rail themes
|
||||||
@@ -384,6 +463,11 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 0.5rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.rail-section-label {
|
.rail-section-label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--sidebar-text-muted);
|
color: var(--sidebar-text-muted);
|
||||||
@@ -438,6 +522,20 @@
|
|||||||
padding: 0.08rem 0 0.1rem;
|
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 {
|
.brand-kicker {
|
||||||
color: var(--sidebar-text-muted);
|
color: var(--sidebar-text-muted);
|
||||||
font-size: 0.66rem;
|
font-size: 0.66rem;
|
||||||
@@ -466,6 +564,10 @@
|
|||||||
gap: 0.18rem;
|
gap: 0.18rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .rail-nav {
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.rail-row {
|
.rail-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -544,6 +646,16 @@
|
|||||||
line-height: 1.5;
|
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 {
|
.rail-row.active .rail-badge {
|
||||||
border-color: color-mix(in srgb, var(--sidebar-active-text) 26%, transparent);
|
border-color: color-mix(in srgb, var(--sidebar-active-text) 26%, transparent);
|
||||||
color: var(--sidebar-active-text);
|
color: var(--sidebar-active-text);
|
||||||
@@ -735,6 +847,24 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.sidebar-meta-foot {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
@@ -809,4 +939,91 @@
|
|||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,36 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Settings, Sparkles } from 'lucide-svelte';
|
import { PanelLeft, Settings, Sparkles } from 'lucide-svelte';
|
||||||
|
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.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 { tooltip } from '$lib/actions/tooltip';
|
||||||
import type { AppSession } from '$lib/session';
|
import type { AppSession } from '$lib/session';
|
||||||
import type { Crumb } from '$lib/navigation/client-navigation';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
breadcrumbs,
|
|
||||||
title,
|
|
||||||
sessionHydrated,
|
sessionHydrated,
|
||||||
session,
|
session,
|
||||||
|
showSidebarToggle,
|
||||||
|
sidebarOpen,
|
||||||
userInitials,
|
userInitials,
|
||||||
userMenuOpen,
|
userMenuOpen,
|
||||||
canUseWorkspaceSearch,
|
canUseWorkspaceSearch,
|
||||||
|
searchQuery = $bindable(''),
|
||||||
|
searchOpen = $bindable(false),
|
||||||
|
searchFocusRequest,
|
||||||
|
filteredSearchItems,
|
||||||
|
hiddenResultCount,
|
||||||
canOpenSettings,
|
canOpenSettings,
|
||||||
onOpenPalette,
|
onRunSearchItem,
|
||||||
|
onToggleSidebar,
|
||||||
onToggleUserMenu,
|
onToggleUserMenu,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onSignOut,
|
onSignOut,
|
||||||
onShowWhatsNew
|
onShowWhatsNew
|
||||||
}: {
|
}: {
|
||||||
breadcrumbs: Crumb[];
|
|
||||||
title: string;
|
|
||||||
sessionHydrated: boolean;
|
sessionHydrated: boolean;
|
||||||
session: AppSession | null;
|
session: AppSession | null;
|
||||||
|
showSidebarToggle: boolean;
|
||||||
|
sidebarOpen: boolean;
|
||||||
userInitials: string;
|
userInitials: string;
|
||||||
userMenuOpen: boolean;
|
userMenuOpen: boolean;
|
||||||
canUseWorkspaceSearch: boolean;
|
canUseWorkspaceSearch: boolean;
|
||||||
|
searchQuery?: string;
|
||||||
|
searchOpen?: boolean;
|
||||||
|
searchFocusRequest: number;
|
||||||
|
filteredSearchItems: SearchItem[];
|
||||||
|
hiddenResultCount: number;
|
||||||
canOpenSettings: boolean;
|
canOpenSettings: boolean;
|
||||||
onOpenPalette: () => void;
|
onRunSearchItem: (item: SearchItem) => void | Promise<void>;
|
||||||
|
onToggleSidebar: () => void;
|
||||||
onToggleUserMenu: () => void;
|
onToggleUserMenu: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onSignOut: () => void;
|
onSignOut: () => void;
|
||||||
@@ -40,27 +52,35 @@
|
|||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-start">
|
<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">
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{#if canUseWorkspaceSearch}
|
{#if canUseWorkspaceSearch}
|
||||||
<div class="topbar-middle">
|
<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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="topbar-middle"></div>
|
<div class="topbar-middle"></div>
|
||||||
@@ -135,10 +155,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.topbar {
|
.topbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* Left flexes/truncates, search shrinks within a cap, and the actions take
|
grid-template-columns: minmax(0, 1fr) minmax(20rem, 3fr) minmax(0, 1fr);
|
||||||
exactly their content width (auto) so they're never squeezed into
|
|
||||||
wrapping. */
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 30rem) auto;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.72rem 1.2rem;
|
padding: 0.72rem 1.2rem;
|
||||||
@@ -174,61 +191,41 @@
|
|||||||
transform-origin: left center;
|
transform-origin: left center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-copy {
|
.sidebar-toggle {
|
||||||
min-width: 0;
|
display: inline-flex;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
justify-content: center;
|
||||||
gap: 0.32rem;
|
width: 2.25rem;
|
||||||
color: var(--muted);
|
height: 2.25rem;
|
||||||
font-size: 0.74rem;
|
flex-shrink: 0;
|
||||||
font-weight: 500;
|
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 {
|
.sidebar-toggle:hover {
|
||||||
color: var(--muted);
|
background: var(--panel-soft);
|
||||||
transition: color 140ms ease;
|
border-color: var(--color-border);
|
||||||
}
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-middle {
|
.topbar-middle {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
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) {
|
:global(.topbar-search) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 2.75rem;
|
/* ~half the topbar on a large laptop; centered within its track. */
|
||||||
background: color-mix(in srgb, var(--panel-soft) 60%, var(--color-bg-surface));
|
max-width: 40rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
@@ -238,6 +235,7 @@
|
|||||||
/* Never let the What's-new / theme toggles wrap above the user button. */
|
/* Never let the What's-new / theme toggles wrap above the user button. */
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Matches the ThemeToggle button so the two sit as a pair. */
|
/* 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
|
/* 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
|
laptop's content width is already tight well above the sidebar's own
|
||||||
collapse point, so keep the top row to brand + actions only. */
|
collapse point, so keep the top row to brand + actions only. */
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1180px) {
|
||||||
.topbar {
|
.topbar {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
@@ -461,6 +459,17 @@
|
|||||||
|
|
||||||
.topbar-middle {
|
.topbar-middle {
|
||||||
grid-area: 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 {
|
.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,
|
deletingId,
|
||||||
sortedEntries,
|
sortedEntries,
|
||||||
paginatedEntries,
|
paginatedEntries,
|
||||||
page,
|
page = $bindable(1),
|
||||||
totalPages,
|
totalPages,
|
||||||
pageStart,
|
pageStart,
|
||||||
pageEnd,
|
pageEnd,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Settings,
|
||||||
Tags,
|
Tags,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Users
|
Users
|
||||||
@@ -90,6 +91,12 @@ export type Crumb = {
|
|||||||
href?: string;
|
href?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PageMeta = {
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
icon: ComponentType;
|
||||||
|
};
|
||||||
|
|
||||||
export const dashboardItem: NavItem = {
|
export const dashboardItem: NavItem = {
|
||||||
href: '/',
|
href: '/',
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
@@ -285,9 +292,10 @@ export const baseSearchItems: SearchItem[] = [
|
|||||||
* Callers pass only the modules the current session may see; empty families
|
* 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.
|
* collapse away so a role with one costing tool never gets an empty group.
|
||||||
*
|
*
|
||||||
* Workflow-family layout: Dashboard, then a "Costing" group (the calculator,
|
* Workflow-family layout: Dashboard, then an "Operations" group (the calculator,
|
||||||
* costing, editor, and master tools), then Operations and Insights modules at
|
* costing, editor, master tools, and throughput), then Ordering and Insights
|
||||||
* the top level until each grows into a family of its own.
|
* modules. Costing tools live inside Operations for the time being until they
|
||||||
|
* grow into a family of their own.
|
||||||
*/
|
*/
|
||||||
export function buildClientNavEntries(visible: {
|
export function buildClientNavEntries(visible: {
|
||||||
dashboard?: NavItem | null;
|
dashboard?: NavItem | null;
|
||||||
@@ -302,10 +310,14 @@ export function buildClientNavEntries(visible: {
|
|||||||
entries.push({ kind: 'item', item: visible.dashboard });
|
entries.push({ kind: 'item', item: visible.dashboard });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visible.costing.length) {
|
const operationsChildren = [
|
||||||
|
...visible.costing,
|
||||||
|
...(visible.throughput ? [visible.throughput] : [])
|
||||||
|
];
|
||||||
|
if (operationsChildren.length) {
|
||||||
entries.push({
|
entries.push({
|
||||||
kind: 'group',
|
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);
|
entries.push(visible.ordering);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visible.throughput) {
|
|
||||||
entries.push({ kind: 'item', item: visible.throughput });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visible.reporting) {
|
if (visible.reporting) {
|
||||||
entries.push({ kind: 'item', item: 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) {
|
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')) {
|
if (pathname.startsWith('/ordering/manage')) {
|
||||||
const section = findOrderingSection(pathname);
|
const section = findOrderingSection(pathname);
|
||||||
return section && section.href !== '/ordering/manage'
|
return {
|
||||||
? `Order Management · ${section.label}`
|
title: section?.label ?? 'Orders',
|
||||||
: 'Order Management';
|
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[] {
|
export function clientBreadcrumbs(pathname: string, session?: AppSession | null): Crumb[] {
|
||||||
|
|||||||
@@ -216,7 +216,223 @@
|
|||||||
.manage-shell .modal h2 { margin: 0 0 1rem; }
|
.manage-shell .modal h2 { margin: 0 0 1rem; }
|
||||||
.manage-shell .modal .actions { justify-content: flex-end; margin-top: 1.15rem; }
|
.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) {
|
@media (max-width: 1000px) {
|
||||||
.manage-shell .split,
|
.manage-shell .split,
|
||||||
|
.manage-shell .console-split,
|
||||||
.manage-shell .form-grid { grid-template-columns: 1fr; }
|
.manage-shell .form-grid { grid-template-columns: 1fr; }
|
||||||
|
.manage-shell .console-split > .detail { position: static; }
|
||||||
|
.manage-shell .workspace { max-height: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,6 +303,12 @@ export type EditorProductUpdateInput = {
|
|||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EditorMixCreateInput = {
|
||||||
|
client_name: string;
|
||||||
|
name: string;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type EditorMixUpdateInput = {
|
export type EditorMixUpdateInput = {
|
||||||
client_name?: string;
|
client_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -338,6 +344,35 @@ export type EditorMixFormula = {
|
|||||||
total_kg: number;
|
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 = {
|
export type EditorProductIngredient = {
|
||||||
id: number;
|
id: number;
|
||||||
raw_material_id: number;
|
raw_material_id: number;
|
||||||
|
|||||||
@@ -437,7 +437,6 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
h2,
|
|
||||||
h3,
|
h3,
|
||||||
h4,
|
h4,
|
||||||
p,
|
p,
|
||||||
@@ -453,21 +452,12 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro,
|
|
||||||
.metric-row,
|
.metric-row,
|
||||||
.workspace-grid,
|
.workspace-grid,
|
||||||
.preview-grid {
|
.preview-grid {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro h2 {
|
|
||||||
margin: 0.35rem 0 0.45rem;
|
|
||||||
max-width: 18ch;
|
|
||||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-intro p:last-child,
|
|
||||||
.metric-card p,
|
.metric-card p,
|
||||||
.card-toolbar p,
|
.card-toolbar p,
|
||||||
.client-row span,
|
.client-row span,
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
import SortHeader from '$lib/table/SortHeader.svelte';
|
import SortHeader from '$lib/table/SortHeader.svelte';
|
||||||
import { TableController } from '$lib/table/table.svelte';
|
import { TableController } from '$lib/table/table.svelte';
|
||||||
import type {
|
import type {
|
||||||
EditorMixFormula,
|
EditorResolvedMixFormula,
|
||||||
EditorMixIngredient,
|
EditorResolvedMixIngredient,
|
||||||
EditorMixRow,
|
EditorMixRow,
|
||||||
EditorMixUpdateInput,
|
EditorMixUpdateInput,
|
||||||
RawMaterial
|
RawMaterial
|
||||||
} from '$lib/types';
|
} 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';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -22,9 +22,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
type DraftIngredient = {
|
type DraftIngredient = {
|
||||||
id: number | null;
|
|
||||||
raw_material_id: number | null;
|
raw_material_id: number | null;
|
||||||
quantity_kg: number;
|
quantity_kg: number;
|
||||||
|
// Percentage is an entry aid; kilograms remain the canonical saved value.
|
||||||
|
percentage: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,8 +35,17 @@
|
|||||||
let visibilityFilter = $state<'all' | 'visible' | 'hidden'>('visible');
|
let visibilityFilter = $state<'all' | 'visible' | 'hidden'>('visible');
|
||||||
let savingKey = $state<string | null>(null);
|
let savingKey = $state<string | null>(null);
|
||||||
let expandedMixId = $state<number | null>(null);
|
let expandedMixId = $state<number | null>(null);
|
||||||
let activeFormula = $state<EditorMixFormula | null>(null);
|
let activeFormula = $state<EditorResolvedMixFormula | null>(null);
|
||||||
let ingredientDrafts = $state<DraftIngredient[]>([]);
|
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 {
|
function toEditableRow(row: EditorMixRow): EditableRow {
|
||||||
return {
|
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 {
|
return {
|
||||||
id: ingredient.id,
|
|
||||||
raw_material_id: ingredient.raw_material_id,
|
raw_material_id: ingredient.raw_material_id,
|
||||||
quantity_kg: ingredient.quantity_kg,
|
quantity_kg: ingredient.quantity_kg,
|
||||||
|
percentage: ingredient.mix_percentage,
|
||||||
notes: ingredient.notes ?? ''
|
notes: ingredient.notes ?? ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyIngredient(): DraftIngredient {
|
function emptyIngredient(): DraftIngredient {
|
||||||
return {
|
return {
|
||||||
id: null,
|
|
||||||
raw_material_id: (data.rawMaterials as RawMaterial[])[0]?.id ?? null,
|
raw_material_id: (data.rawMaterials as RawMaterial[])[0]?.id ?? null,
|
||||||
quantity_kg: 0,
|
quantity_kg: 0,
|
||||||
|
percentage: 0,
|
||||||
notes: ''
|
notes: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadIngredientDrafts(formula: EditorMixFormula) {
|
function loadIngredientDrafts(formula: EditorResolvedMixFormula) {
|
||||||
activeFormula = formula;
|
activeFormula = formula;
|
||||||
|
totalReference = formula.total_kg || 0;
|
||||||
ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()];
|
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) {
|
function rowDirty(row: EditableRow) {
|
||||||
return row.draft_mix_name !== row.name || row.draft_visible !== row.visible;
|
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));
|
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) {
|
async function toggleIngredients(row: EditableRow) {
|
||||||
if (expandedMixId === row.id) {
|
if (expandedMixId === row.id) {
|
||||||
expandedMixId = null;
|
expandedMixId = null;
|
||||||
@@ -116,7 +219,7 @@
|
|||||||
savingKey = `mix-load:${row.id}`;
|
savingKey = `mix-load:${row.id}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loadIngredientDrafts(await api.editorMixFormula(row.id));
|
loadIngredientDrafts(await api.editorMixResolvedFormula(row.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expandedMixId = null;
|
expandedMixId = null;
|
||||||
toast.error(error instanceof Error ? error.message : 'Unable to load ingredients');
|
toast.error(error instanceof Error ? error.message : 'Unable to load ingredients');
|
||||||
@@ -132,6 +235,7 @@
|
|||||||
function removeIngredient(index: number) {
|
function removeIngredient(index: number) {
|
||||||
ingredientDrafts = ingredientDrafts.filter((_, rowIndex) => rowIndex !== index);
|
ingredientDrafts = ingredientDrafts.filter((_, rowIndex) => rowIndex !== index);
|
||||||
if (!ingredientDrafts.length) ingredientDrafts = [emptyIngredient()];
|
if (!ingredientDrafts.length) ingredientDrafts = [emptyIngredient()];
|
||||||
|
applyKgEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function ingredientWarnings() {
|
function ingredientWarnings() {
|
||||||
@@ -148,6 +252,11 @@
|
|||||||
if (Number(row.quantity_kg) <= 0) return [`Ingredient row ${index + 1} needs a quantity greater than zero.`];
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,42 +271,13 @@
|
|||||||
savingKey = `mix-save:${activeFormula.id}`;
|
savingKey = `mix-save:${activeFormula.id}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cleanRows = ingredientDrafts.map((row) => ({
|
const payloadRows = ingredientDrafts.map((row) => ({
|
||||||
id: row.id,
|
|
||||||
raw_material_id: row.raw_material_id as number,
|
raw_material_id: row.raw_material_id as number,
|
||||||
quantity_kg: Number(row.quantity_kg),
|
quantity_kg: Number(row.quantity_kg),
|
||||||
notes: row.notes.trim() || null
|
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) {
|
loadIngredientDrafts(await api.replaceEditorMixFormula(activeFormula.id, payloadRows));
|
||||||
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));
|
|
||||||
toast.success('Ingredients saved');
|
toast.success('Ingredients saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Unable to save ingredients');
|
toast.error(error instanceof Error ? error.message : 'Unable to save ingredients');
|
||||||
@@ -262,6 +342,10 @@
|
|||||||
const ingredientTotalKg = $derived(
|
const ingredientTotalKg = $derived(
|
||||||
ingredientDrafts.reduce((sum, ingredient) => sum + Number(ingredient.quantity_kg || 0), 0)
|
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.
|
// Jump back to the first page whenever the filtered set changes.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -340,18 +424,59 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl class="facts">
|
<div class="status-actions">
|
||||||
<div class="fact">
|
<dl class="facts">
|
||||||
<dt>Mixes</dt>
|
<div class="fact">
|
||||||
<dd>{visibleRows.length}</dd>
|
<dt>Mixes</dt>
|
||||||
</div>
|
<dd>{visibleRows.length}</dd>
|
||||||
<div class="fact">
|
</div>
|
||||||
<dt>Unsaved</dt>
|
<div class="fact">
|
||||||
<dd>{dirtyCount}</dd>
|
<dt>Unsaved</dt>
|
||||||
</div>
|
<dd>{dirtyCount}</dd>
|
||||||
</dl>
|
</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>
|
</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">
|
<div class="pagination-bar" aria-label="Mix table pagination">
|
||||||
<span>{table.pageStart}-{table.pageEnd} of {table.total}</span>
|
<span>{table.pageStart}-{table.pageEnd} of {table.total}</span>
|
||||||
<label class="page-size">
|
<label class="page-size">
|
||||||
@@ -420,17 +545,30 @@
|
|||||||
<div class="ingredient-panel" transition:fade={{ duration: 120 }}>
|
<div class="ingredient-panel" transition:fade={{ duration: 120 }}>
|
||||||
<div class="ingredient-head">
|
<div class="ingredient-head">
|
||||||
<div>
|
<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>
|
<strong>{activeFormula?.client_name ?? row.client_name} / {activeFormula?.name ?? row.name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="ingredient-summary">
|
<div class="ingredient-summary">
|
||||||
<span>{ingredientDrafts.length} rows</span>
|
<label class="total-field">
|
||||||
<span>{ingredientTotalKg.toFixed(2)} kg</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="ingredient-grid">
|
<div class="ingredient-grid">
|
||||||
<span class="grid-label">Raw material</span>
|
<span class="grid-label">Raw material</span>
|
||||||
|
<span class="grid-label">%</span>
|
||||||
<span class="grid-label">kg</span>
|
<span class="grid-label">kg</span>
|
||||||
<span class="grid-label">Notes</span>
|
<span class="grid-label">Notes</span>
|
||||||
<span class="grid-label">Remove</span>
|
<span class="grid-label">Remove</span>
|
||||||
@@ -441,8 +579,17 @@
|
|||||||
<option value={material.id}>{material.name}</option>
|
<option value={material.id}>{material.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</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
|
<input
|
||||||
bind:value={ingredient.quantity_kg}
|
bind:value={ingredient.quantity_kg}
|
||||||
|
onchange={applyKgEdit}
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.0001"
|
step="0.0001"
|
||||||
@@ -454,8 +601,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ingredient-footer">
|
<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="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'}
|
{savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -602,12 +750,69 @@
|
|||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-mix-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.facts {
|
.facts {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.35rem;
|
gap: 1.35rem;
|
||||||
margin: 0;
|
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 {
|
.fact {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1004,11 +1209,47 @@
|
|||||||
|
|
||||||
.ingredient-grid {
|
.ingredient-grid {
|
||||||
display: 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;
|
gap: 0.45rem;
|
||||||
align-items: center;
|
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 {
|
.remove-button {
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
border-color: color-mix(in srgb, var(--color-error) 38%, var(--color-border));
|
border-color: color-mix(in srgb, var(--color-error) 38%, var(--color-border));
|
||||||
|
|||||||
@@ -180,14 +180,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ordering">
|
<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">
|
<div class="layout">
|
||||||
<!-- Catalogue -->
|
<!-- Catalogue -->
|
||||||
<section class="catalogue surface-card">
|
<section class="catalogue surface-card">
|
||||||
@@ -334,12 +326,9 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.ordering { display: grid; gap: 1.25rem; }
|
.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; }
|
h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
||||||
h3 { margin: 0; font-size: 0.98rem; }
|
h3 { margin: 0; font-size: 0.98rem; }
|
||||||
p { margin: 0; }
|
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; }
|
.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; }
|
.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; }
|
.toolbar { display: grid; gap: 0.6rem; margin-bottom: 1rem; }
|
||||||
|
|||||||
@@ -1,29 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
|
||||||
import '$lib/ordering/manage.css';
|
import '$lib/ordering/manage.css';
|
||||||
import { findOrderingSection } from '$lib/navigation/client-navigation';
|
|
||||||
|
|
||||||
let { children } = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- Section navigation lives in the primary left rail (and the mobile drawer),
|
<!-- Section navigation lives in the primary left rail (and the mobile drawer),
|
||||||
so the console pages don't repeat it inline. -->
|
so the console pages don't repeat it inline. -->
|
||||||
<div class="manage-shell">
|
<div class="manage-shell">
|
||||||
<header>
|
|
||||||
<p class="eyebrow">Order Management</p>
|
|
||||||
<h1>{sectionLabel}</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.manage-shell { display: grid; gap: 1rem; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ClipboardList, X } from 'lucide-svelte';
|
||||||
|
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
|
import { tooltip } from '$lib/actions/tooltip';
|
||||||
import { money, label, statusTone, ORDER_STATUSES } from '$lib/ordering/format';
|
import { money, label, statusTone, ORDER_STATUSES } from '$lib/ordering/format';
|
||||||
import type { Order } from '$lib/types';
|
import type { Order } from '$lib/types';
|
||||||
|
|
||||||
@@ -11,6 +14,16 @@
|
|||||||
orders = data.orders ?? [];
|
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 selectedOrder = $state<Order | null>(null);
|
||||||
let statusChoice = $state('');
|
let statusChoice = $state('');
|
||||||
|
|
||||||
@@ -26,6 +39,9 @@
|
|||||||
selectedOrder = null;
|
selectedOrder = null;
|
||||||
statusChoice = '';
|
statusChoice = '';
|
||||||
}
|
}
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && selectedOrder) closeOrder();
|
||||||
|
}
|
||||||
async function refreshOrders() {
|
async function refreshOrders() {
|
||||||
try {
|
try {
|
||||||
orders = await api.orderingAdmin.orders();
|
orders = await api.orderingAdmin.orders();
|
||||||
@@ -45,7 +61,10 @@
|
|||||||
async function overrideLine(lineId: number, value: string) {
|
async function overrideLine(lineId: number, value: string) {
|
||||||
if (!selectedOrder || value === '') return;
|
if (!selectedOrder || value === '') return;
|
||||||
try {
|
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.');
|
toast.success('Line price overridden.');
|
||||||
await refreshOrders();
|
await refreshOrders();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -75,83 +94,167 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="surface-card">
|
<svelte:window onkeydown={handleWindowKeydown} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if selectedOrder}
|
<div class="console-split">
|
||||||
<div class="modal-backdrop" role="presentation" onclick={closeOrder}>
|
<!-- ── Left: order queue ─────────────────────────────────────────────────── -->
|
||||||
<div
|
<section class="surface-card">
|
||||||
class="modal wide detail"
|
<div class="card-head">
|
||||||
role="dialog"
|
<h2>Orders <span class="count">{filteredOrders.length}</span></h2>
|
||||||
aria-modal="true"
|
</div>
|
||||||
aria-label="Order detail"
|
|
||||||
tabindex="-1"
|
<input class="list-search" type="search" placeholder="Search order or customer" bind:value={listQuery} />
|
||||||
onclick={(event) => event.stopPropagation()}
|
|
||||||
onkeydown={(event) => { if (event.key === 'Escape') closeOrder(); }}
|
{#if filteredOrders.length}
|
||||||
>
|
<table class="clickable">
|
||||||
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
|
<thead>
|
||||||
<p class="muted">{selectedOrder.customer_name} · {label(selectedOrder.status)} · PO {selectedOrder.purchase_order_number ?? '—'}</p>
|
<tr><th>Order</th><th>Status</th><th class="amt">Subtotal</th></tr>
|
||||||
<table class="lines">
|
</thead>
|
||||||
<thead><tr><th>Product</th><th>Qty</th><th>Unit</th><th>Override</th><th>Total</th></tr></thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each selectedOrder.lines as l (l.id)}
|
{#each filteredOrders as o (o.id)}
|
||||||
<tr>
|
<tr
|
||||||
<td>{l.product_name}</td>
|
class:selected={selectedOrder?.id === o.id}
|
||||||
<td>{l.quantity}</td>
|
tabindex="0"
|
||||||
<td>{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
|
role="button"
|
||||||
|
aria-pressed={selectedOrder?.id === o.id}
|
||||||
|
onclick={() => openOrder(o)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
openOrder(o);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<td>
|
<td>
|
||||||
<input class="ovr" type="number" step="0.01" placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'}
|
<div class="id-cell">
|
||||||
onchange={(e) => overrideLine(l.id, e.currentTarget.value)} />
|
<span class="id-name">{o.order_number ?? `#${o.id}`}</span>
|
||||||
|
<span class="id-sub">{o.customer_name ?? 'Unknown customer'}</span>
|
||||||
|
</div>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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">
|
<!-- ── Right: order detail ───────────────────────────────────────────────── -->
|
||||||
<select bind:value={statusChoice}>
|
{#if selectedOrder}
|
||||||
<option value="">Change status…</option>
|
<section class="surface-card detail workspace">
|
||||||
{#each ORDER_STATUSES as s}<option value={s}>{label(s)}</option>{/each}
|
<div class="workspace-head">
|
||||||
</select>
|
<div class="detail-head head-row">
|
||||||
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
|
<div class="detail-title">
|
||||||
<button class="secondary" onclick={sendToXero}>Send to Xero</button>
|
<p class="eyebrow">{selectedOrder.customer_name ?? 'Order'}</p>
|
||||||
<button class="secondary" onclick={reopenOrder}>Reopen</button>
|
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
|
||||||
<button class="secondary" onclick={closeOrder}>Close</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{#if selectedOrder.status_history?.length}
|
<div class="workspace-body">
|
||||||
<details class="history">
|
<div class="meta">
|
||||||
<summary>Status history ({selectedOrder.status_history.length})</summary>
|
<div class="meta-item"><span>PO number</span><strong>{selectedOrder.purchase_order_number ?? '—'}</strong></div>
|
||||||
<ul>
|
<div class="meta-item"><span>Fulfilment</span><strong>{label(selectedOrder.fulfilment_method)}</strong></div>
|
||||||
{#each selectedOrder.status_history as h}
|
<div class="meta-item"><span>Xero</span><strong>{selectedOrder.xero_status ?? 'Not sent'}</strong></div>
|
||||||
<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>
|
||||||
|
|
||||||
|
<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}
|
{/each}
|
||||||
</ul>
|
</tbody>
|
||||||
</details>
|
</table>
|
||||||
{/if}
|
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
|
||||||
</div>
|
|
||||||
</div>
|
{#if selectedOrder.status_history?.length}
|
||||||
{/if}
|
<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">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
import { Building2, Plus } from 'lucide-svelte';
|
||||||
|
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
import { statusTone } from '$lib/ordering/format';
|
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();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -12,30 +15,49 @@
|
|||||||
customers = data.customers ?? [];
|
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 newCustomer = $state({ name: '', client_code: '' });
|
||||||
let showNewCustomer = $state(false);
|
let showNewCustomer = $state(false);
|
||||||
let newCustomerNameInput: HTMLInputElement | null = $state(null);
|
let newCustomerNameInput: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
function openNewCustomer() {
|
function toggleNewCustomer() {
|
||||||
newCustomer = { name: '', client_code: '' };
|
showNewCustomer = !showNewCustomer;
|
||||||
showNewCustomer = true;
|
if (showNewCustomer) newCustomer = { name: '', client_code: '' };
|
||||||
}
|
|
||||||
function closeNewCustomer() {
|
|
||||||
showNewCustomer = false;
|
|
||||||
}
|
}
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showNewCustomer) tick().then(() => newCustomerNameInput?.focus());
|
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 {
|
try {
|
||||||
customers = await api.orderingAdmin.customers();
|
customers = await api.orderingAdmin.customers();
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCustomer() {
|
async function createCustomer() {
|
||||||
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
|
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
|
||||||
try {
|
try {
|
||||||
@@ -48,137 +70,101 @@
|
|||||||
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="surface-card">
|
<svelte:window onkeydown={handleWindowKeydown} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if selectedCustomer}
|
<div class="console-split">
|
||||||
<section class="surface-card detail">
|
<!-- ── Left: customer roster ─────────────────────────────────────────────── -->
|
||||||
<h2>{selectedCustomer.name}</h2>
|
<section class="surface-card">
|
||||||
|
<div class="card-head">
|
||||||
<h3>Users</h3>
|
<h2>Customers <span class="count">{filteredCustomers.length}</span></h2>
|
||||||
<ul class="mini">
|
<button class="primary" onclick={toggleNewCustomer}>
|
||||||
{#each custUsers as u (u.id)}
|
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
|
||||||
<li>{u.full_name} · {u.email} · {u.role} · {u.status}
|
{showNewCustomer ? 'Cancel' : 'New customer'}
|
||||||
<button class="link" onclick={() => toggleUserStatus(u)}>{u.status === 'suspended' ? 'Reactivate' : 'Suspend'}</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<h3 class="mt">Product visibility</h3>
|
{#if showNewCustomer}
|
||||||
<ul class="mini visibility">
|
<div class="create-panel">
|
||||||
{#each custVisibility as row (row.product_id)}
|
<div class="form-grid">
|
||||||
<li>
|
<label class="full">Company name
|
||||||
<label class="check"><input type="checkbox" checked={row.visible} onchange={() => toggleVisibility(row)} /> {row.name}</label>
|
<input bind:this={newCustomerNameInput} bind:value={newCustomer.name} />
|
||||||
</li>
|
</label>
|
||||||
{/each}
|
<label class="full">Client code
|
||||||
</ul>
|
<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>
|
</section>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showNewCustomer}
|
<!-- ── Right: customer workspace ─────────────────────────────────────────── -->
|
||||||
<div class="modal-backdrop" role="presentation" onclick={closeNewCustomer}>
|
{#if selectedCustomer}
|
||||||
<div
|
<CustomerWorkspace customer={selectedCustomer} onChanged={refreshCustomers} onClose={closeDetail} />
|
||||||
class="modal"
|
{:else}
|
||||||
role="dialog"
|
<section class="surface-card detail empty-detail">
|
||||||
aria-modal="true"
|
<span class="empty-icon" aria-hidden="true"><Building2 size={26} strokeWidth={1.7} /></span>
|
||||||
aria-label="New customer"
|
<h2>Select a customer</h2>
|
||||||
tabindex="-1"
|
<p class="muted">
|
||||||
onclick={(event) => event.stopPropagation()}
|
Pick a company to open its workspace: details, people, catalogue access, orders, mixes, and history in one place.
|
||||||
onkeydown={(event) => { if (event.key === 'Escape') closeNewCustomer(); }}
|
</p>
|
||||||
>
|
</section>
|
||||||
<h2>New customer</h2>
|
{/if}
|
||||||
<div class="form-grid">
|
</div>
|
||||||
<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>
|
<style>
|
||||||
</div>
|
.amt { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
<div class="actions">
|
|
||||||
<button class="secondary" onclick={closeNewCustomer}>Cancel</button>
|
.primary { gap: 0.4rem; }
|
||||||
<button class="primary" onclick={createCustomer}>Create customer</button>
|
.primary :global(svg) { display: block; }
|
||||||
</div>
|
|
||||||
</div>
|
.clickable tbody tr:focus-visible {
|
||||||
</div>
|
outline: 2px solid var(--color-brand);
|
||||||
{/if}
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
import { Plus } from 'lucide-svelte';
|
||||||
|
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
|
import { tooltip } from '$lib/actions/tooltip';
|
||||||
import { label, PRODUCT_CATEGORIES } from '$lib/ordering/format';
|
import { label, PRODUCT_CATEGORIES } from '$lib/ordering/format';
|
||||||
import type { CatalogueProduct } from '$lib/types';
|
import type { CatalogueProduct } from '$lib/types';
|
||||||
|
|
||||||
@@ -12,17 +15,35 @@
|
|||||||
products = data.products ?? [];
|
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 newProduct = $state<Record<string, any>>(blankProduct());
|
||||||
let showNewProduct = $state(false);
|
let showNewProduct = $state(false);
|
||||||
let newProductNameInput: HTMLInputElement | null = $state(null);
|
let newProductNameInput: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
function openNewProduct() {
|
function toggleNewProduct() {
|
||||||
newProduct = blankProduct();
|
showNewProduct = !showNewProduct;
|
||||||
showNewProduct = true;
|
if (showNewProduct) newProduct = blankProduct();
|
||||||
}
|
|
||||||
function closeNewProduct() {
|
|
||||||
showNewProduct = false;
|
|
||||||
}
|
}
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showNewProduct) tick().then(() => newProductNameInput?.focus());
|
if (showNewProduct) tick().then(() => newProductNameInput?.focus());
|
||||||
@@ -36,7 +57,11 @@
|
|||||||
async function createProduct() {
|
async function createProduct() {
|
||||||
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
|
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
|
||||||
try {
|
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.');
|
toast.success('Product created.');
|
||||||
newProduct = blankProduct();
|
newProduct = blankProduct();
|
||||||
showNewProduct = false;
|
showNewProduct = false;
|
||||||
@@ -66,43 +91,22 @@
|
|||||||
|
|
||||||
<section class="surface-card">
|
<section class="surface-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>Catalogue ({products.length})</h2>
|
<h2>Catalogue <span class="count">{filteredProducts.length}</span></h2>
|
||||||
<button class="primary" onclick={openNewProduct}>New product</button>
|
<button class="primary" onclick={toggleNewProduct}>
|
||||||
|
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
{showNewProduct ? 'Cancel' : 'New product'}
|
||||||
|
</button>
|
||||||
</div>
|
</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}
|
{#if showNewProduct}
|
||||||
<div class="modal-backdrop" role="presentation" onclick={closeNewProduct}>
|
<div class="create-panel">
|
||||||
<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>
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>Name<input bind:this={newProductNameInput} bind:value={newProduct.name} /></label>
|
<label>Name<input bind:this={newProductNameInput} bind:value={newProduct.name} /></label>
|
||||||
<label>SKU<input bind:value={newProduct.sku} /></label>
|
<label>SKU<input bind:value={newProduct.sku} /></label>
|
||||||
<label>Category
|
<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>
|
||||||
<label>Unit of measure<input bind:value={newProduct.unit_of_measure} /></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>
|
<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>
|
<label class="check full"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<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>
|
<button class="primary" onclick={createProduct}>Create product</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</script>
|
||||||
|
|
||||||
<section class="costing-shell">
|
<section class="costing-shell">
|
||||||
<header class="page-head">
|
<div class="head-actions">
|
||||||
<div>
|
<button class="primary-button" disabled={recalculating} type="button" onclick={recalculateAll}>
|
||||||
<span class="eyebrow">Alpha</span>
|
<span class:spin={recalculating} aria-hidden="true"><RefreshCcw size={17} /></span>
|
||||||
<h2>Product Costing</h2>
|
{recalculating ? 'Updating' : 'Update Prices'}
|
||||||
<p>Check prices, fix warnings, and update costing settings.</p>
|
</button>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
<section class="health-strip" aria-label="Product costing health summary">
|
<section class="health-strip" aria-label="Product costing health summary">
|
||||||
<article class="health-card">
|
<article class="health-card">
|
||||||
@@ -715,7 +708,6 @@
|
|||||||
animation: spin 900ms linear infinite;
|
animation: spin 900ms linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2,
|
|
||||||
h3,
|
h3,
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -763,7 +755,6 @@
|
|||||||
--costing-warn-row: var(--color-warning);
|
--costing-warn-row: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-head,
|
|
||||||
.health-strip,
|
.health-strip,
|
||||||
.workspace-grid,
|
.workspace-grid,
|
||||||
.section-toolbar,
|
.section-toolbar,
|
||||||
@@ -776,13 +767,6 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-head {
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.35rem 0 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-head p,
|
|
||||||
.section-toolbar p,
|
.section-toolbar p,
|
||||||
.block-heading span,
|
.block-heading span,
|
||||||
small,
|
small,
|
||||||
@@ -795,22 +779,6 @@
|
|||||||
color: var(--costing-muted);
|
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 {
|
.head-actions {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -162,7 +162,6 @@
|
|||||||
<section class="add-entry">
|
<section class="add-entry">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<a class="back-link" href="/throughput"><ArrowLeft size={16} /> Back to log</a>
|
<a class="back-link" href="/throughput"><ArrowLeft size={16} /> Back to log</a>
|
||||||
<h1>Add Throughput Entry</h1>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if successMessage}
|
{#if successMessage}
|
||||||
@@ -275,10 +274,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.page-header h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
.back-link {
|
.back-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Reference in New Issue
Block a user