Compare commits

...
6 Commits
Author SHA1 Message Date
admin 7db95e2027 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
2026-06-16 14:43:17 +12:00
admin 8f9a7b8193 Release v0.1.23 2026-06-15 11:59:04 +12:00
admin 250d6ab6a9 Release v0.1.22 2026-06-15 10:13:02 +12:00
adminandClaude Opus 4.8 8b81f804f7 v0.1.21 - Mix calculator composer restyle
Mirror the throughput composer's green design language on the mix calculator
inputs: brand-tinted composer surface with a calculator-icon header, green
48px fields, Mix Name disabled until a client is chosen, a darker readable
on-brand hint, and optional notes via the "+ Add a note" reveal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:26:07 +12:00
adminandClaude Opus 4.8 b1c0d3f3da v0.1.20 - Even composer fields on laptops & add-success modal
- Throughput: even three-across layout for the "Add a packing run" fields
  on laptop widths so placeholders stay readable in the narrow content band
- Throughput: centred "Added" confirmation modal with in-card confetti on
  save (3s), composer clears for the next entry
- Fix stray character in changelog.ts type comment

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:25:52 +12:00
adminandClaude Opus 4.8 2de82776cb v0.1.19 - Throughput overview & responsive header
- Throughput: new "Throughput Overview" header with Gauge icon and brand-green
  badge icons on each card (Today, This week, 4-week average, Horse Mix, Grain Mix)
- Throughput: inline rolling-range selector (7d / 4w / 6w / 12w, default 4 weeks)
  driving the customer-mix cards; stats window widened to 12 weeks so switching
  range is a pure client-side re-filter
- Throughput: cards collapse to a single even 5-across row on laptop and up,
  with container-query value text that scales to each card's width
- Throughput: date logic pinned to Australian Eastern time (fixes the day-early
  date); This week subtitle shows the Mon-Sun date range
- Throughput: subtler tinted add-form; removed the inline-entry kicker and the
  "Open full form" link
- Topbar: fix cramped laptop header - action toggles no longer wrap above the
  user button; search drops to its own row earlier

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:01:10 +12:00
101 changed files with 12542 additions and 4076 deletions
+7
View File
@@ -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
+5
View File
@@ -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 300500 lines, split components.
### Dependencies ### Dependencies
Current app dependency entry points: Current app dependency entry points:
+431 -11
View File
@@ -1,22 +1,34 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select from sqlalchemy import case, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload, selectinload from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession, get_auth_session from app.api.deps import AuthSession, get_auth_session
from app.db.session import get_db from app.db.session import get_db
from app.models.mix import Mix from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial from app.models.raw_material import RawMaterial
from app.schemas.editor import ( from app.schemas.editor import (
EditorIngredientCreate,
EditorIngredientRow,
EditorIngredientUpdate,
EditorMixFormulaRead,
EditorMixCreate,
EditorMixFormulaReplace,
EditorMixIngredientCreate,
EditorMixIngredientUpdate,
EditorMixRow,
EditorMixUpdate, EditorMixUpdate,
EditorProductFormulaRead, EditorProductFormulaRead,
EditorProductIngredientCreate, EditorProductIngredientCreate,
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.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"])
@@ -63,6 +75,65 @@ def _serialize_product_formula(product: Product) -> dict:
} }
def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict:
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"visible": visible_count > 0,
"product_count": product_count,
"visible_product_count": visible_count,
"notes": mix.notes,
}
def _mix_product_counts(db: Session, tenant_id: str) -> dict[int, tuple[int, int]]:
"""Per-mix (total products, visible products) used to drive the Status column."""
rows = db.execute(
select(
Product.mix_id,
func.count(),
func.sum(case((Product.visible, 1), else_=0)),
)
.where(Product.tenant_id == tenant_id)
.group_by(Product.mix_id)
).all()
return {mix_id: (int(total), int(visible or 0)) for mix_id, total, visible in rows}
def _serialize_mix_formula(mix: Mix) -> dict:
ingredients = [
{
"id": ingredient.id,
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"notes": ingredient.notes,
}
for ingredient in sorted(
mix.ingredients,
key=lambda item: item.raw_material.name if item.raw_material else "",
)
]
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"ingredients": ingredients,
"total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
}
def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None:
return db.scalar(
select(Mix)
.where(Mix.id == mix_id, Mix.tenant_id == tenant_id)
.options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
)
def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None: def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None:
return db.scalar( return db.scalar(
select(Product) select(Product)
@@ -156,7 +227,53 @@ def update_editor_product(
return _serialize_row(product) return _serialize_row(product)
@router.patch("/mixes/{mix_id}", response_model=list[EditorProductRow]) @router.get("/mixes", response_model=list[EditorMixRow])
def list_editor_mixes(
q: str | None = Query(default=None, max_length=255),
client_name: str | None = Query(default=None, max_length=255),
limit: int = Query(default=500, ge=1, le=1000),
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
statement = select(Mix).where(Mix.tenant_id == session.tenant_id)
if client_name:
statement = statement.where(Mix.client_name == client_name)
if q:
term = f"%{q.strip()}%"
statement = statement.where(or_(Mix.client_name.ilike(term), Mix.name.ilike(term)))
statement = statement.order_by(Mix.client_name, Mix.name, Mix.id).limit(limit)
counts = _mix_product_counts(db, session.tenant_id or "")
mixes = db.scalars(statement).all()
return [
_serialize_mix_row(mix, visible_count=counts.get(mix.id, (0, 0))[1], product_count=counts.get(mix.id, (0, 0))[0])
for mix in mixes
]
@router.post("/mixes", response_model=EditorMixRow, status_code=201)
def create_editor_mix(
payload: EditorMixCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = Mix(
tenant_id=session.tenant_id or "",
client_name=payload.client_name.strip(),
name=payload.name.strip(),
notes=payload.notes,
)
db.add(mix)
db.commit()
db.refresh(mix)
# A brand-new mix has no products yet, so it reads as Inactive (no visible products).
return _serialize_mix_row(mix, visible_count=0, product_count=0)
@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
def update_editor_mix( def update_editor_mix(
mix_id: int, mix_id: int,
payload: EditorMixUpdate, payload: EditorMixUpdate,
@@ -167,18 +284,208 @@ def update_editor_mix(
if mix is None: if mix is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items(): updates = payload.model_dump(exclude_unset=True)
# `visible` is a virtual field: it fans out to the visibility of every product
# under the mix rather than mapping to a mix column.
visible = updates.pop("visible", None)
for field, value in updates.items():
setattr(mix, field, value) setattr(mix, field, value)
if visible is not None:
for product in db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
).all():
product.visible = visible
db.commit() db.commit()
products = db.scalars( counts = _mix_product_counts(db, session.tenant_id or "")
select(Product) total, visible_count = counts.get(mix_id, (0, 0))
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id) return _serialize_mix_row(mix, visible_count=visible_count, product_count=total)
.options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name, Product.id)
).all() @router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead)
return [_serialize_row(product) for product in products] def get_editor_mix_ingredients(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
return _serialize_mix_formula(mix)
@router.post("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead, status_code=201)
def add_editor_mix_ingredient(
mix_id: int,
payload: EditorMixIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Raw material not found")
db.add(
MixIngredient(
tenant_id=session.tenant_id or "",
mix_id=mix_id,
raw_material_id=payload.raw_material_id,
quantity_kg=payload.quantity_kg,
notes=payload.notes,
)
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=400, detail="Raw material is already on this mix") from exc
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
return _serialize_mix_formula(mix)
@router.patch("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
def update_editor_mix_ingredient(
mix_id: int,
ingredient_id: int,
payload: EditorMixIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(MixIngredient)
.join(Mix)
.where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
Mix.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(ingredient, field, value)
db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
return _serialize_mix_formula(mix)
@router.delete("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
def delete_editor_mix_ingredient(
mix_id: int,
ingredient_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(MixIngredient)
.join(Mix)
.where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
Mix.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
db.delete(ingredient)
db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
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)
@@ -282,3 +589,116 @@ def delete_editor_product_ingredient(
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "") product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product) return _serialize_product_formula(product)
# --- Ingredients (raw materials) catalogue -----------------------------------
#
# The mix editor consumes raw materials as the ingredients dropdown; this gives
# Lean admins a sibling editor to curate that catalogue — the ingredients that
# ultimately get used inside mixes. Same auth/tenant model as the mix editor.
def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict:
active_price = get_active_price(material)
cost_per_kg = (
calculate_raw_material_cost(material, active_price).cost_per_kg if active_price is not None else None
)
return {
"id": material.id,
"name": material.name,
"supplier": material.supplier,
"unit_of_measure": material.unit_of_measure,
"kg_per_unit": material.kg_per_unit,
"status": material.status,
"rounding_decimals": material.rounding_decimals,
"notes": material.notes,
"cost_per_kg": cost_per_kg,
"usage_count": usage_count,
"created_at": material.created_at,
}
def _ingredient_usage_counts(db: Session, tenant_id: str) -> dict[int, int]:
"""How many product/mix formula rows reference each raw material."""
rows = db.execute(
select(ProductIngredient.raw_material_id, func.count())
.where(ProductIngredient.tenant_id == tenant_id)
.group_by(ProductIngredient.raw_material_id)
).all()
return {raw_material_id: count for raw_material_id, count in rows}
@router.get("/ingredients", response_model=list[EditorIngredientRow])
def list_editor_ingredients(
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == tenant_id)
.options(selectinload(RawMaterial.price_versions))
.order_by(RawMaterial.name)
).all()
usage = _ingredient_usage_counts(db, tenant_id)
return [_serialize_ingredient(material, usage.get(material.id, 0)) for material in materials]
@router.post("/ingredients", response_model=EditorIngredientRow, status_code=201)
def create_editor_ingredient(
payload: EditorIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
material = RawMaterial(
tenant_id=session.tenant_id or "",
name=payload.name.strip(),
supplier=(payload.supplier or "").strip() or None,
unit_of_measure=payload.unit_of_measure.strip(),
kg_per_unit=payload.kg_per_unit,
status=payload.status.strip() or "active",
rounding_decimals=payload.rounding_decimals,
notes=payload.notes,
)
db.add(material)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
db.refresh(material)
return _serialize_ingredient(material, 0)
@router.patch("/ingredients/{ingredient_id}", response_model=EditorIngredientRow)
def update_editor_ingredient(
ingredient_id: int,
payload: EditorIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
material = db.scalar(
select(RawMaterial)
.where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)
.options(selectinload(RawMaterial.price_versions))
)
if material is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
updates = payload.model_dump(exclude_unset=True)
if "name" in updates and updates["name"] is not None:
updates["name"] = updates["name"].strip()
if "supplier" in updates:
updates["supplier"] = (updates["supplier"] or "").strip() or None
if "unit_of_measure" in updates and updates["unit_of_measure"] is not None:
updates["unit_of_measure"] = updates["unit_of_measure"].strip()
for field, value in updates.items():
setattr(material, field, value)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
db.refresh(material)
usage = _ingredient_usage_counts(db, tenant_id)
return _serialize_ingredient(material, usage.get(material.id, 0))
+162 -3
View File
@@ -7,9 +7,10 @@ within the seller's ordering tenant.
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
@@ -27,6 +28,7 @@ from app.models.ordering import (
PriceListItem, PriceListItem,
PriceTier, PriceTier,
ProductCategory, ProductCategory,
XeroContactLink,
XeroSyncLog, XeroSyncLog,
) )
from app.schemas.ordering import ( from app.schemas.ordering import (
@@ -47,6 +49,7 @@ from app.schemas.ordering import (
PriceListItemUpsert, PriceListItemUpsert,
ReopenOrderRequest, ReopenOrderRequest,
VisibilityUpdate, VisibilityUpdate,
XeroContactLinkUpsert,
) )
from app.services import ordering_service as svc from app.services import ordering_service as svc
from app.services.client_access_service import ( from app.services.client_access_service import (
@@ -54,7 +57,11 @@ from app.services.client_access_service import (
record_audit_event, record_audit_event,
) )
from app.services.order_notifications import get_or_create_settings from app.services.order_notifications import get_or_create_settings
from app.services.xero_service import submit_order_to_xero, xero_status_snapshot from app.services.xero_service import (
list_xero_contacts,
submit_order_to_xero,
xero_status_snapshot,
)
router = APIRouter(prefix="/api/ordering-admin", tags=["ordering-admin"]) router = APIRouter(prefix="/api/ordering-admin", tags=["ordering-admin"])
@@ -122,11 +129,21 @@ def _serialize_tiers(db: Session, *, customer_product_price_id=None, price_list_
# --- Customers --------------------------------------------------------------- # --- Customers ---------------------------------------------------------------
def _xero_link_for(db: Session, customer_id: int) -> XeroContactLink | None:
return db.scalar(
select(XeroContactLink).where(
XeroContactLink.tenant_id == TENANT,
XeroContactLink.client_account_id == customer_id,
)
)
def _serialize_customer(db: Session, account: ClientAccount) -> dict: def _serialize_customer(db: Session, account: ClientAccount) -> dict:
users = db.scalars(select(ClientUser).where(ClientUser.client_account_id == account.id)).all() users = db.scalars(select(ClientUser).where(ClientUser.client_account_id == account.id)).all()
assignment = db.scalar( assignment = db.scalar(
select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == account.id) select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == account.id)
) )
link = _xero_link_for(db, account.id)
return { return {
"id": account.id, "id": account.id,
"name": account.name, "name": account.name,
@@ -137,6 +154,8 @@ def _serialize_customer(db: Session, account: ClientAccount) -> dict:
"user_count": len(users), "user_count": len(users),
"price_list_id": assignment.price_list_id if assignment else None, "price_list_id": assignment.price_list_id if assignment else None,
"discount_percent": assignment.discount_percent if assignment else 0.0, "discount_percent": assignment.discount_percent if assignment else 0.0,
"xero_contact_id": link.xero_contact_id if link else None,
"xero_contact_name": link.xero_contact_name if link else None,
"created_at": account.created_at, "created_at": account.created_at,
} }
@@ -905,7 +924,10 @@ def send_order_to_xero(
if order.status not in {"confirmed", "in_production", "sent_to_xero"}: if order.status not in {"confirmed", "in_production", "sent_to_xero"}:
raise HTTPException(status_code=409, detail="Only confirmed orders can be sent to Xero") raise HTTPException(status_code=409, detail="Only confirmed orders can be sent to Xero")
account = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id)) account = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
result = submit_order_to_xero(order, account) link = _xero_link_for(db, order.client_account_id)
result = submit_order_to_xero(order, account, link)
if link is not None and result.status == "success":
link.last_synced_at = datetime.utcnow()
db.add( db.add(
XeroSyncLog( XeroSyncLog(
@@ -985,8 +1007,19 @@ def get_xero_status(
recent = db.scalars( recent = db.scalars(
select(XeroSyncLog).where(XeroSyncLog.tenant_id == TENANT).order_by(XeroSyncLog.created_at.desc()).limit(20) select(XeroSyncLog).where(XeroSyncLog.tenant_id == TENANT).order_by(XeroSyncLog.created_at.desc()).limit(20)
).all() ).all()
total_customers = db.scalar(
select(func.count()).select_from(ClientAccount)
) or 0
linked_customers = db.scalar(
select(func.count()).select_from(XeroContactLink).where(XeroContactLink.tenant_id == TENANT)
) or 0
return { return {
"connection": xero_status_snapshot(), "connection": xero_status_snapshot(),
"contact_links": {
"linked": linked_customers,
"total": total_customers,
"unlinked": max(total_customers - linked_customers, 0),
},
"recent_syncs": [ "recent_syncs": [
{ {
"id": log.id, "id": log.id,
@@ -999,3 +1032,129 @@ def get_xero_status(
for log in recent for log in recent
], ],
} }
# --- Xero contact mapping ----------------------------------------------------
@router.get("/xero/contacts")
def list_available_xero_contacts(
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
"""The Xero contacts available to link a customer against (stub or live)."""
contacts, stubbed = list_xero_contacts()
return {"contacts": [c.as_dict() for c in contacts], "stubbed": stubbed}
@router.get("/xero/contact-links")
def list_xero_contact_links(
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
"""Every customer with its current Xero link and a suggested match.
The suggestion is a best-effort name match against the available contacts so
the operator can confirm rather than hunt through a dropdown.
"""
contacts, _ = list_xero_contacts()
by_name = {c.name.strip().lower(): c for c in contacts}
accounts = db.scalars(select(ClientAccount).order_by(ClientAccount.name)).all()
rows = []
for account in accounts:
link = _xero_link_for(db, account.id)
suggestion = None if link else by_name.get(account.name.strip().lower())
rows.append(
{
"customer_id": account.id,
"customer_name": account.name,
"client_code": account.client_code,
"linked": link is not None,
"xero_contact_id": link.xero_contact_id if link else None,
"xero_contact_name": link.xero_contact_name if link else None,
"xero_contact_email": link.xero_contact_email if link else None,
"last_synced_at": link.last_synced_at if link else None,
"suggested_contact_id": suggestion.contact_id if suggestion else None,
}
)
return rows
@router.put("/customers/{customer_id}/xero-link")
def link_customer_to_xero(
customer_id: int,
payload: XeroContactLinkUpsert,
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
account = _customer_or_404(db, customer_id)
# Default the cached name/email from the known contact list when the caller
# only sends an id, so the mapping reads nicely without a live round-trip.
name = payload.xero_contact_name
email = payload.xero_contact_email
if name is None or email is None:
contacts, _ = list_xero_contacts()
match = next((c for c in contacts if c.contact_id == payload.xero_contact_id), None)
if match is not None:
name = name or match.name
email = email or match.email
link = _xero_link_for(db, customer_id)
if link is None:
link = XeroContactLink(
tenant_id=TENANT,
client_account_id=customer_id,
xero_contact_id=payload.xero_contact_id,
xero_contact_name=name,
xero_contact_email=email,
)
db.add(link)
else:
link.xero_contact_id = payload.xero_contact_id
link.xero_contact_name = name
link.xero_contact_email = email
record_audit_event(
db,
tenant_id=account.tenant_id,
client_account_id=account.id,
action="xero.contact_linked",
target_type="xero_contact_link",
target_id=account.id,
module_key="ordering",
summary=f"{account.name} linked to Xero contact {name or payload.xero_contact_id}.",
**_actor(session),
)
db.commit()
return {
"customer_id": customer_id,
"linked": True,
"xero_contact_id": link.xero_contact_id,
"xero_contact_name": link.xero_contact_name,
"xero_contact_email": link.xero_contact_email,
}
@router.delete("/customers/{customer_id}/xero-link", status_code=status.HTTP_204_NO_CONTENT)
def unlink_customer_from_xero(
customer_id: int,
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
account = _customer_or_404(db, customer_id)
link = _xero_link_for(db, customer_id)
if link is not None:
db.delete(link)
record_audit_event(
db,
tenant_id=account.tenant_id,
client_account_id=account.id,
action="xero.contact_unlinked",
target_type="xero_contact_link",
target_id=account.id,
module_key="ordering",
summary=f"{account.name} unlinked from Xero contact.",
**_actor(session),
)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+78
View File
@@ -0,0 +1,78 @@
"""Read-only external data API (`/api/v1`).
A deliberately simple, API-key authenticated surface for Power BI (and any other
external reporting tool). It is intentionally separate from the cookie/JWT
session model used by the operator frontend: external tools cannot hold a
browser session, so they present a single static key instead.
Authentication: send the key either as an ``X-API-Key`` request header or an
``api_key`` query-string parameter (Power BI's Web connector supports both).
The key is configured via the ``POWERBI_API_KEY`` environment variable; when it
is blank the whole API is disabled and every request returns 503.
"""
from __future__ import annotations
import secrets
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security_logging import log_security_event
from app.db.session import get_db
from app.models.throughput import ProductionThroughput
from app.services.throughput_service import serialize_entry
router = APIRouter(prefix="/api/v1", tags=["public-v1"])
_API_KEY_HEADER = "X-API-Key"
def require_powerbi_api_key(request: Request) -> str:
"""Authorize an external request via the static Power BI API key.
Returns the tenant the caller may read. Raises 503 when the API is not
configured, or 401 when the key is missing/incorrect.
"""
configured = settings.powerbi_api_key
if not configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The data API is not configured.",
)
presented = request.headers.get(_API_KEY_HEADER) or request.query_params.get("api_key") or ""
# Constant-time comparison so the endpoint does not leak key length/contents
# through response timing.
if not presented or not secrets.compare_digest(presented, configured):
log_security_event("authz.denied", role="powerbi", reason="invalid_api_key")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key")
return settings.powerbi_tenant_id
@router.get("/throughput")
def list_throughput(
date_from: date | None = Query(default=None, description="Only entries on/after this production date (YYYY-MM-DD)."),
date_to: date | None = Query(default=None, description="Only entries on/before this production date (YYYY-MM-DD)."),
limit: int = Query(default=5000, ge=1, le=50000),
tenant_id: str = Depends(require_powerbi_api_key),
db: Session = Depends(get_db),
):
"""Flat list of production throughput entries for Power BI.
One row per packing run, oldest first so incremental refreshes append
naturally. Each row carries the same fields the operator UI shows
(date, product, bag size, quantity, calculated kg, QA flags, staff, notes).
"""
stmt = select(ProductionThroughput).where(ProductionThroughput.tenant_id == tenant_id)
if date_from is not None:
stmt = stmt.where(ProductionThroughput.production_date >= date_from)
if date_to is not None:
stmt = stmt.where(ProductionThroughput.production_date <= date_to)
stmt = stmt.order_by(ProductionThroughput.production_date.asc(), ProductionThroughput.id.asc()).limit(limit)
return [serialize_entry(entry) for entry in db.scalars(stmt).all()]
+37 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -13,16 +13,21 @@ from app.schemas.throughput import (
ThroughputEntryCreate, ThroughputEntryCreate,
ThroughputEntryRead, ThroughputEntryRead,
ThroughputEntryUpdate, ThroughputEntryUpdate,
ThroughputImportResult,
ThroughputProductCreate, ThroughputProductCreate,
ThroughputProductRead, ThroughputProductRead,
ThroughputProductUpdate, ThroughputProductUpdate,
) )
from app.services.throughput_service import ( from app.services.throughput_service import (
calculate_kg, calculate_kg,
import_entries_from_file,
normalise_staff_name, normalise_staff_name,
serialize_entry, serialize_entry,
) )
# Uploaded files larger than this are rejected before we read them into memory.
_MAX_IMPORT_BYTES = 10 * 1024 * 1024 # 10 MB
router = APIRouter(prefix="/api/throughput", tags=["operations-throughput"]) router = APIRouter(prefix="/api/throughput", tags=["operations-throughput"])
MODULE_KEY = "operations_throughput" MODULE_KEY = "operations_throughput"
@@ -184,6 +189,33 @@ def create_entry(
return serialize_entry(entry) return serialize_entry(entry)
@router.post("/import", response_model=ThroughputImportResult)
def import_entries(
file: UploadFile = File(...),
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
content = file.file.read()
if not content:
raise HTTPException(status_code=400, detail="The uploaded file is empty.")
if len(content) > _MAX_IMPORT_BYTES:
raise HTTPException(status_code=413, detail="File is too large. Keep uploads under 10 MB.")
try:
result = import_entries_from_file(
db,
filename=file.filename or "upload.csv",
content=content,
tenant_id=session.tenant_id,
created_by=session.email,
)
except ValueError as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
return result
@router.get("/entries/{entry_id}", response_model=ThroughputEntryRead) @router.get("/entries/{entry_id}", response_model=ThroughputEntryRead)
def get_entry( def get_entry(
entry_id: int, entry_id: int,
@@ -233,7 +265,10 @@ def update_entry(
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_entry( def delete_entry(
entry_id: int, entry_id: int,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "manage")), # Correcting a mistaken run is part of day-to-day operating, so deleting an
# entry sits at the same "edit" level as adding/editing one. (No throughput
# role is granted "manage", so requiring it here would 403 everyone.)
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
entry = db.scalar( entry = db.scalar(
+8
View File
@@ -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
+2
View File
@@ -129,6 +129,8 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
("production_throughput_entries", "for_stock", "BOOLEAN NOT NULL DEFAULT FALSE"), ("production_throughput_entries", "for_stock", "BOOLEAN NOT NULL DEFAULT FALSE"),
("production_throughput_entries", "job_number", "VARCHAR(64)"), ("production_throughput_entries", "job_number", "VARCHAR(64)"),
("production_throughput_entries", "stock_quantity", "FLOAT"), ("production_throughput_entries", "stock_quantity", "FLOAT"),
("raw_materials", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
("mix_calculator_session_lines", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
) )
+8
View File
@@ -30,6 +30,7 @@ from app.api.ordering_admin import router as ordering_admin_router
from app.api.powerbi import router as powerbi_router from app.api.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",
}, },
} }
+2
View File
@@ -52,6 +52,8 @@ class MixCalculatorSessionLine(Base):
required_kg: Mapped[float] = mapped_column(Float) required_kg: Mapped[float] = mapped_column(Float)
mix_percentage: Mapped[float] = mapped_column(Float) mix_percentage: Mapped[float] = mapped_column(Float)
unit: Mapped[str] = mapped_column(String(64)) unit: Mapped[str] = mapped_column(String(64))
# Snapshot of the ingredient's rounding setting at save time.
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
sort_order: Mapped[int] = mapped_column(Integer, default=0) sort_order: Mapped[int] = mapped_column(Integer, default=0)
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines") session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
+28
View File
@@ -376,6 +376,34 @@ class NotificationSetting(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class XeroContactLink(Base):
"""Persistent link between a customer (:class:`ClientAccount`) and a Xero
contact.
Maintained from the Integrations console. Once a customer is linked, order
invoices reference the real Xero ``ContactID`` instead of falling back to the
client code which is what lets Xero attach the invoice to the right
contact rather than creating a duplicate. One link per customer per tenant.
"""
__tablename__ = "xero_contact_links"
__table_args__ = (
UniqueConstraint("tenant_id", "client_account_id", name="uq_xero_contact_link_customer"),
)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
xero_contact_id: Mapped[str] = mapped_column(String(128))
xero_contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
xero_contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# When the contact details were last reconciled with Xero (a future live
# sync can refresh name/email and stamp this).
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class XeroSyncLog(Base): class XeroSyncLog(Base):
__tablename__ = "xero_sync_log" __tablename__ = "xero_sync_log"
+4 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base from app.db.session import Base
@@ -18,6 +18,9 @@ class RawMaterial(Base):
unit_of_measure: Mapped[str] = mapped_column(String(64)) unit_of_measure: Mapped[str] = mapped_column(String(64))
kg_per_unit: Mapped[float] = mapped_column(Float) kg_per_unit: Mapped[float] = mapped_column(Float)
status: Mapped[str] = mapped_column(String(32), default="active") status: Mapped[str] = mapped_column(String(32), default="active")
# Decimal places this ingredient's required-kg is rounded to in the mix
# calculator output. Set per-ingredient from the Ingredients Editor.
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
notes: Mapped[str | None] = mapped_column(Text, nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+148
View File
@@ -1,3 +1,5 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -30,12 +32,115 @@ 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")
client_name: str | None = Field(default=None, min_length=1, max_length=255) client_name: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255) name: str | None = Field(default=None, min_length=1, max_length=255)
notes: str | None = Field(default=None, max_length=2000) notes: str | None = Field(default=None, max_length=2000)
# Toggling a mix's status fans out to the visibility of every product under it.
visible: bool | None = None
class EditorMixRow(BaseModel):
id: int
tenant_id: str
client_name: str
name: str
# A mix reads as "Active" when at least one of its products is visible.
visible: bool
product_count: int
visible_product_count: int
notes: str | None
class EditorMixIngredientRead(BaseModel):
id: int
raw_material_id: int
raw_material_name: str
quantity_kg: float
notes: str | None
class EditorMixFormulaRead(BaseModel):
id: int
tenant_id: str
client_name: str
name: str
ingredients: list[EditorMixIngredientRead]
total_kg: float
class EditorResolvedMixIngredient(BaseModel):
raw_material_id: int
raw_material_name: str
quantity_kg: float
# This row's share of the mix total, matching the Mix Calculator.
mix_percentage: float
unit: str
notes: str | None
class EditorResolvedMixFormula(BaseModel):
"""A mix formula resolved the way the Mix Calculator reads it.
`source` is `product` when the numbers come from a representative product's
own formula, or `mix` when they come from the shared mix master fallback.
The PUT endpoint writes back to whichever source produced these rows.
"""
id: int
tenant_id: str
client_name: str
name: str
source: str
product_id: int | None
ingredients: list[EditorResolvedMixIngredient]
total_kg: float
class EditorMixFormulaRowInput(BaseModel):
model_config = ConfigDict(extra="forbid")
raw_material_id: int
quantity_kg: float = Field(gt=0)
notes: str | None = Field(default=None, max_length=1000)
class EditorMixFormulaReplace(BaseModel):
"""Full replacement of a mix's formula in one save.
The frontend keeps kilograms as the canonical value (percentages are an
entry aid that resolve back to kg against the total), so the API only needs
the resolved kg per row.
"""
model_config = ConfigDict(extra="forbid")
rows: list[EditorMixFormulaRowInput] = Field(min_length=1)
class EditorMixIngredientCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
raw_material_id: int
quantity_kg: float = Field(gt=0)
notes: str | None = Field(default=None, max_length=1000)
class EditorMixIngredientUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
quantity_kg: float | None = Field(default=None, gt=0)
notes: str | None = Field(default=None, max_length=1000)
class EditorProductIngredientCreate(BaseModel): class EditorProductIngredientCreate(BaseModel):
@@ -71,3 +176,46 @@ class EditorProductFormulaRead(BaseModel):
mix_name: str mix_name: str
ingredients: list[EditorProductIngredientRead] ingredients: list[EditorProductIngredientRead]
total_kg: float total_kg: float
# --- Ingredients (raw materials) catalogue -----------------------------------
class EditorIngredientRow(BaseModel):
id: int
name: str
supplier: str | None
unit_of_measure: str
kg_per_unit: float
status: str
# Decimal places this ingredient is rounded to in the mix calculator output.
rounding_decimals: int
notes: str | None
cost_per_kg: float | None
# How many product/mix formulas currently reference this ingredient.
usage_count: int
created_at: datetime
class EditorIngredientCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
supplier: str | None = Field(default=None, max_length=255)
unit_of_measure: str = Field(min_length=1, max_length=64)
kg_per_unit: float = Field(gt=0)
status: str = Field(default="active", max_length=32)
rounding_decimals: int = Field(default=2, ge=0, le=6)
notes: str | None = Field(default=None, max_length=2000)
class EditorIngredientUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=255)
supplier: str | None = Field(default=None, max_length=255)
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
kg_per_unit: float | None = Field(default=None, gt=0)
status: str | None = Field(default=None, max_length=32)
rounding_decimals: int | None = Field(default=None, ge=0, le=6)
notes: str | None = Field(default=None, max_length=2000)
+1
View File
@@ -26,6 +26,7 @@ class MixCalculatorSessionLineRead(BaseModel):
required_kg: float required_kg: float
mix_percentage: float mix_percentage: float
unit: str unit: str
rounding_decimals: int = 2
sort_order: int sort_order: int
+12
View File
@@ -238,6 +238,18 @@ class NotificationSettingsUpdate(BaseModel):
from_email: str | None = None from_email: str | None = None
# --- Admin: Xero integration -------------------------------------------------
class XeroContactLinkUpsert(BaseModel):
"""Link a customer to a Xero contact. ``xero_contact_id`` is the Xero
``ContactID`` (or, in stub mode, the deterministic stub id)."""
xero_contact_id: str = Field(min_length=1, max_length=128)
xero_contact_name: str | None = Field(default=None, max_length=255)
xero_contact_email: str | None = Field(default=None, max_length=255)
# --- Admin: customers & users ------------------------------------------------ # --- Admin: customers & users ------------------------------------------------
_CUSTOMER_STATUSES = {"active", "disabled"} _CUSTOMER_STATUSES = {"active", "disabled"}
+7
View File
@@ -117,6 +117,13 @@ class ThroughputEntryUpdate(BaseModel):
notes: str | None = Field(default=None, max_length=2000) notes: str | None = Field(default=None, max_length=2000)
class ThroughputImportResult(BaseModel):
entries_imported: int
entries_skipped: int
products_created: int
errors: list[str] = Field(default_factory=list)
class ThroughputEntryRead(BaseModel): class ThroughputEntryRead(BaseModel):
id: int id: int
tenant_id: str tenant_id: str
+4 -1
View File
@@ -319,7 +319,10 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210), fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
) )
pdf.setFont("Helvetica", table_font_size) pdf.setFont("Helvetica", table_font_size)
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}kg") # Each ingredient carries its own rounding (set in the Ingredients Editor)
# so the printed sheet matches the on-screen calculated output.
line_decimals = getattr(line, "rounding_decimals", 2)
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg, line_decimals)}kg")
strip_y = table_bottom - 6 strip_y = table_bottom - 6
if note_lines: if note_lines:
@@ -43,6 +43,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
"raw_material_name": ingredient.raw_material.name, "raw_material_name": ingredient.raw_material.name,
"quantity_kg": ingredient.quantity_kg, "quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure, "unit": ingredient.raw_material.unit_of_measure,
"rounding_decimals": ingredient.raw_material.rounding_decimals,
"sort_order": ingredient.sort_order, "sort_order": ingredient.sort_order,
} }
for ingredient in product.ingredients for ingredient in product.ingredients
@@ -55,6 +56,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {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, "quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg", "unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
"sort_order": index, "sort_order": index,
} }
for index, ingredient in enumerate(product.mix.ingredients, start=1) for index, ingredient in enumerate(product.mix.ingredients, start=1)
@@ -91,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,
*, *,
@@ -128,6 +220,7 @@ def calculate_mix_calculator_preview(
"required_kg": required_kg, "required_kg": required_kg,
"mix_percentage": mix_percentage, "mix_percentage": mix_percentage,
"unit": ingredient["unit"], "unit": ingredient["unit"],
"rounding_decimals": ingredient.get("rounding_decimals", 2),
"sort_order": ingredient["sort_order"] or index, "sort_order": ingredient["sort_order"] or index,
} }
) )
@@ -260,6 +353,7 @@ def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_
"required_kg": round(line.required_kg, 4), "required_kg": round(line.required_kg, 4),
"mix_percentage": round(line.mix_percentage, 4), "mix_percentage": round(line.mix_percentage, 4),
"unit": line.unit, "unit": line.unit,
"rounding_decimals": line.rounding_decimals,
"sort_order": line.sort_order, "sort_order": line.sort_order,
} }
for line in session_record.lines for line in session_record.lines
@@ -331,6 +425,7 @@ def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, pay
required_kg=line["required_kg"], required_kg=line["required_kg"],
mix_percentage=line["mix_percentage"], mix_percentage=line["mix_percentage"],
unit=line["unit"], unit=line["unit"],
rounding_decimals=line.get("rounding_decimals", 2),
sort_order=line["sort_order"], sort_order=line["sort_order"],
) )
for line in preview["lines"] for line in preview["lines"]
+332
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import csv
import io
import logging import logging
import os import os
from datetime import date, datetime from datetime import date, datetime
@@ -99,6 +101,29 @@ def _coerce_bool(value: object) -> bool:
return True return True
def _coerce_import_bool(value: object, *, default: bool = False) -> bool:
"""Conservative boolean parsing for ad-hoc imports.
Uploaded CSV/XLSX rows often leave destination columns blank, or use text
like "stock" / "order" elsewhere in the row. Those should not silently
become True. Only explicit truthy markers opt in.
"""
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if not text:
return default
if text in {"yes", "y", "true", "1", "pass", "ok", "x", "checked"}:
return True
if text in {"no", "n", "false", "0", "fail"}:
return False
return default
def _coerce_float(value: object) -> float | None: def _coerce_float(value: object) -> float | None:
if value is None or value == "": if value is None or value == "":
return None return None
@@ -369,3 +394,310 @@ def resolve_workbook_path() -> Path | None:
if candidate.exists(): if candidate.exists():
return candidate return candidate
return None return None
# ── Ad-hoc CSV / spreadsheet upload import ──────────────────────────────────
# Lets an operator upload their own CSV or .xlsx of packing runs (from Settings
# → Import) and have every row saved as a throughput entry. Unlike the bundled
# workbook seed above, this is column-header driven so the file can be a simple
# hand-built sheet rather than the exact "Operations Throughput.xlsx" layout.
# Maps the column headers we accept (normalised: lower-cased, spaces/dashes →
# single spaces) onto the canonical field used internally. Several aliases per
# field so a human-built sheet "just works".
_HEADER_ALIASES: dict[str, str] = {
"date": "date",
"production date": "date",
"production_date": "date",
"product": "product",
"product name": "product",
"product_name": "product",
"product name snapshot": "product",
"name": "product",
"item id": "item_id",
"item_id": "item_id",
"itemid": "item_id",
"sku": "item_id",
"quantity": "quantity",
"qty": "quantity",
"packed": "quantity",
"quantity packed": "quantity",
"amount": "quantity",
"quantity type": "quantity_type",
"type": "quantity_type",
"unit": "quantity_type",
"packed as": "quantity_type",
"bag size": "bag_size",
"bag_size": "bag_size",
"kg per bag": "bag_size",
"kg/bag": "bag_size",
"bagsize": "bag_size",
"staff": "staff_name",
"staff name": "staff_name",
"packed by": "staff_name",
"operator": "staff_name",
"for order": "for_order",
"order": "for_order",
"for stock": "for_stock",
"stock": "for_stock",
"job number": "job_number",
"job": "job_number",
"job no": "job_number",
"order number": "job_number",
"stock quantity": "stock_quantity",
"stock qty": "stock_quantity",
"sample box no": "sample_box_no",
"sample box": "sample_box_no",
"scales checked": "scales_checked",
"scales": "scales_checked",
"label correct": "label_correct",
"label": "label_correct",
"bag sealed": "bag_sealed",
"sealed": "bag_sealed",
"pallet good condition": "pallet_good_condition",
"pallet": "pallet_good_condition",
"notes": "notes",
"note": "notes",
"comment": "notes",
"comments": "notes",
}
# How many row-level errors we collect before truncating, to keep the response
# (and the toast) sane on a badly-formed file.
_MAX_REPORTED_ERRORS = 50
def _normalise_header(raw: object) -> str | None:
if raw is None:
return None
key = " ".join(str(raw).strip().lower().replace("-", " ").replace("_", " ").split())
if not key:
return None
if key in _HEADER_ALIASES:
return _HEADER_ALIASES[key]
# Test weights: "test weight 1".."test weight 5" (and "tw1" style).
for n in range(1, 6):
if key in {f"test weight {n}", f"tw{n}", f"test {n}"}:
return f"test_weight_{n}"
return None
def _coerce_quantity_type(value: object) -> str | None:
if value is None:
return None
text = str(value).strip().lower()
if not text:
return None
if text in {"bag", "bags", "b"}:
return "bags"
if text in {"kg", "kgs", "kilogram", "kilograms", "bulka", "bulk"}:
return "kg"
return None
def _read_tabular_file(filename: str, content: bytes) -> tuple[list[str | None], list[tuple]]:
"""Return (headers, data_rows). Detects CSV vs .xlsx by extension/content."""
lowered = (filename or "").lower()
is_excel = lowered.endswith((".xlsx", ".xlsm", ".xls"))
if is_excel:
workbook = load_workbook(io.BytesIO(content), data_only=True, read_only=True)
ws = workbook.active
rows = [tuple(r) for r in ws.iter_rows(values_only=True)]
workbook.close()
else:
text = None
for encoding in ("utf-8-sig", "utf-8", "latin-1"):
try:
text = content.decode(encoding)
break
except UnicodeDecodeError:
continue
if text is None:
raise ValueError("Could not decode the file as text. Save it as UTF-8 CSV or .xlsx.")
# Sniff the delimiter (comma/semicolon/tab) but fall back to comma.
sample = text[:4096]
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except csv.Error:
dialect = csv.excel
rows = [tuple(r) for r in csv.reader(io.StringIO(text), dialect)]
# Find the first row that has at least one recognised header; treat it as
# the header row and everything after as data.
for index, row in enumerate(rows):
if any(_normalise_header(cell) is not None for cell in row):
return list(row), rows[index + 1 :]
return [], []
def import_entries_from_file(
db: Session,
*,
filename: str,
content: bytes,
tenant_id: str,
created_by: str | None,
) -> dict:
"""Parse an uploaded CSV/spreadsheet and persist each row as a throughput
entry. Products are matched by item_id then name, and auto-created when not
found so every entry stays linked. Returns a summary with row-level errors.
"""
headers, data_rows = _read_tabular_file(filename, content)
if not headers:
raise ValueError(
"No recognised columns found. The file needs a header row with at "
"least Date, Product and Quantity columns."
)
# Map canonical field name → column index. First occurrence wins.
field_index: dict[str, int] = {}
for col, raw in enumerate(headers):
field = _normalise_header(raw)
if field and field not in field_index:
field_index[field] = col
for required in ("date", "product", "quantity"):
if required not in field_index:
raise ValueError(
f"Missing required '{required}' column. Required columns are "
"Date, Product and Quantity."
)
def cell(row: tuple, field: str) -> object:
idx = field_index.get(field)
if idx is None or idx >= len(row):
return None
return row[idx]
# Index existing products for matching (by item_id and by lower-cased name).
by_item: dict[str, ThroughputProduct] = {}
by_name: dict[str, ThroughputProduct] = {}
for product in db.scalars(
select(ThroughputProduct).where(ThroughputProduct.tenant_id == tenant_id)
).all():
if product.item_id:
by_item[str(product.item_id)] = product
by_name[product.name.lower()] = product
imported = 0
skipped = 0
products_created = 0
errors: list[str] = []
def note_error(message: str) -> None:
if len(errors) < _MAX_REPORTED_ERRORS:
errors.append(message)
for offset, row in enumerate(data_rows):
# Sheet/file row number for human-friendly error messages (header = 1).
line_no = offset + 2
if not row or all(value is None or str(value).strip() == "" for value in row):
continue
production_date = _coerce_date(cell(row, "date"))
product_name = _coerce_text(cell(row, "product"))
quantity = _coerce_float(cell(row, "quantity"))
if production_date is None:
skipped += 1
note_error(f"Row {line_no}: missing or invalid date.")
continue
if not product_name:
skipped += 1
note_error(f"Row {line_no}: missing product name.")
continue
if quantity is None or quantity < 0:
skipped += 1
note_error(f"Row {line_no}: missing or invalid quantity.")
continue
bag_size = _coerce_float(cell(row, "bag_size"))
quantity_type = _coerce_quantity_type(cell(row, "quantity_type"))
if quantity_type is None:
# Infer: bulka-style rows have a blank or very large bag size.
if bag_size is None or bag_size >= _BULKA_BAG_SIZE_THRESHOLD or "bulka" in product_name.lower():
quantity_type = "kg"
else:
quantity_type = "bags"
if quantity_type == "bags" and (bag_size is None or bag_size <= 0):
skipped += 1
note_error(f"Row {line_no}: bag size is required when packed as bags.")
continue
item_id_raw = cell(row, "item_id")
item_id = None
if item_id_raw is not None:
if isinstance(item_id_raw, float) and item_id_raw.is_integer():
item_id = str(int(item_id_raw))
else:
item_id = _coerce_text(item_id_raw)
product = (by_item.get(item_id) if item_id else None) or by_name.get(product_name.lower())
if product is None:
product = ThroughputProduct(
tenant_id=tenant_id,
item_id=item_id,
name=product_name,
default_bag_size=bag_size,
is_bulka_default=_infer_bulka_default(product_name, bag_size),
active=True,
notes="Auto-created during throughput import",
)
db.add(product)
db.flush()
products_created += 1
if item_id:
by_item[item_id] = product
by_name[product_name.lower()] = product
for_order = _coerce_import_bool(cell(row, "for_order")) if field_index.get("for_order") is not None else False
for_stock = _coerce_import_bool(cell(row, "for_stock")) if field_index.get("for_stock") is not None else False
stock_quantity = _coerce_float(cell(row, "stock_quantity")) if for_stock else None
calculated = calculate_kg(quantity, quantity_type, bag_size)
entry = ProductionThroughput(
tenant_id=tenant_id,
production_date=production_date,
product_id=product.id,
product_name_snapshot=product_name,
bag_size=bag_size,
scales_checked=_coerce_bool(cell(row, "scales_checked")),
label_correct=_coerce_bool(cell(row, "label_correct")),
bag_sealed=_coerce_bool(cell(row, "bag_sealed")),
pallet_good_condition=_coerce_bool(cell(row, "pallet_good_condition")),
for_order=for_order,
for_stock=for_stock,
job_number=_coerce_text(cell(row, "job_number")) if for_order else None,
stock_quantity=stock_quantity,
sample_box_no=_coerce_text(cell(row, "sample_box_no")),
test_weight_1=_coerce_float(cell(row, "test_weight_1")),
test_weight_2=_coerce_float(cell(row, "test_weight_2")),
test_weight_3=_coerce_float(cell(row, "test_weight_3")),
test_weight_4=_coerce_float(cell(row, "test_weight_4")),
test_weight_5=_coerce_float(cell(row, "test_weight_5")),
quantity=quantity,
quantity_type=quantity_type,
calculated_kg=calculated,
staff_name=normalise_staff_name(cell(row, "staff_name")),
notes=_coerce_text(cell(row, "notes")),
created_by=created_by or "csv-import",
)
db.add(entry)
imported += 1
if imported == 0 and products_created == 0:
# Nothing landed — don't leave a half-open transaction.
db.rollback()
else:
db.commit()
return {
"entries_imported": imported,
"entries_skipped": skipped,
"products_created": products_created,
"errors": errors,
}
+80 -11
View File
@@ -23,7 +23,7 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from app.models.client_access import ClientAccount from app.models.client_access import ClientAccount
from app.models.ordering import Order from app.models.ordering import Order, XeroContactLink
@dataclass @dataclass
@@ -57,11 +57,75 @@ class XeroSubmissionResult:
line_items: list[dict] = field(default_factory=list) line_items: list[dict] = field(default_factory=list)
def map_customer_to_contact(customer: ClientAccount) -> dict: @dataclass
"""Map a customer account onto a Xero contact payload.""" class XeroContact:
"""A Xero contact available to link a customer against."""
contact_id: str
name: str
email: str | None = None
status: str = "ACTIVE"
def as_dict(self) -> dict:
return {
"contact_id": self.contact_id,
"name": self.name,
"email": self.email,
"status": self.status,
}
# Deterministic sample contacts used while running in stub mode (no Xero
# credentials). They stand in for "what's in Xero" so the customer→contact
# mapping UI is usable before the live API is wired. Ids mimic Xero GUIDs.
_STUB_CONTACTS: tuple[XeroContact, ...] = (
XeroContact("STUB-CON-0001", "Hunter Premium Produce", "accounts@hunterpremium.example", "ACTIVE"),
XeroContact("STUB-CON-0002", "Mayreef Pty Ltd", "ap@mayreef.example", "ACTIVE"),
XeroContact("STUB-CON-0003", "Ian McKay Stock Feeds", "ian@mckayfeeds.example", "ACTIVE"),
XeroContact("STUB-CON-0004", "Peckish Bird Foods", "orders@peckish.example", "ACTIVE"),
XeroContact("STUB-CON-0005", "Hay & Straw Co", "info@hayandstraw.example", "ACTIVE"),
XeroContact("STUB-CON-0006", "PHF Horse Mixes", "accounts@phfhorse.example", "ACTIVE"),
)
def _fetch_contacts_from_api(config: XeroConfig) -> list[XeroContact]:
"""Live contact fetch. Stubbed until credentials/endpoints are wired.
TODO (go-live): GET ``{config.base_url}/Contacts`` with the
``Xero-tenant-id`` header, page through ``Contacts[]`` and map each onto a
:class:`XeroContact` (``ContactID``/``Name``/``EmailAddress``/``ContactStatus``).
"""
raise NotImplementedError("Live Xero contact fetch is not implemented yet.")
def list_xero_contacts(config: XeroConfig | None = None) -> tuple[list[XeroContact], bool]:
"""Return the Xero contacts available for linking and whether they're stubbed.
Never raises on a live-mode error it returns an empty list so the mapping
console still renders.
"""
config = config or XeroConfig.from_env()
if not config.configured:
return list(_STUB_CONTACTS), True
try:
return _fetch_contacts_from_api(config), False
except Exception: # pragma: no cover - defensive: never break the request path
return [], False
def map_customer_to_contact(customer: ClientAccount, link: XeroContactLink | None = None) -> dict:
"""Map a customer account onto a Xero contact payload.
When the customer has been linked to a Xero contact we send the real
``ContactID`` so Xero attaches the invoice to the existing contact. Without a
link we fall back to keying on the client code (Xero will match-or-create).
"""
if link is not None and link.xero_contact_id:
return {
"ContactID": link.xero_contact_id,
"Name": link.xero_contact_name or customer.name,
}
return { return {
# TODO: persist and reuse a real Xero ContactID once the contact has
# been created/matched in Xero. For now we key on the client code.
"ContactNumber": customer.client_code, "ContactNumber": customer.client_code,
"Name": customer.name, "Name": customer.name,
} }
@@ -76,7 +140,9 @@ def map_product_to_item_code(product_sku: str) -> str:
return product_sku return product_sku
def build_invoice_payload(order: Order, customer: ClientAccount) -> dict: def build_invoice_payload(
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
) -> dict:
"""Build the Xero draft-invoice payload for a confirmed order.""" """Build the Xero draft-invoice payload for a confirmed order."""
line_items = [] line_items = []
for line in order.lines: for line in order.lines:
@@ -98,7 +164,7 @@ def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
return { return {
"Type": "ACCREC", "Type": "ACCREC",
"Status": "DRAFT", "Status": "DRAFT",
"Contact": map_customer_to_contact(customer), "Contact": map_customer_to_contact(customer, link),
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}", "Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
"LineAmountTypes": "Exclusive", "LineAmountTypes": "Exclusive",
"LineItems": line_items, "LineItems": line_items,
@@ -127,14 +193,17 @@ def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResu
) )
def submit_order_to_xero(order: Order, customer: ClientAccount) -> XeroSubmissionResult: def submit_order_to_xero(
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
) -> XeroSubmissionResult:
"""Submit a confirmed order to Xero, or stub it when unconfigured. """Submit a confirmed order to Xero, or stub it when unconfigured.
Never raises failures are returned as ``status="failed"`` results so the Pass ``link`` to invoice against the customer's mapped Xero contact. Never
order lifecycle can record the attempt and continue. raises failures are returned as ``status="failed"`` results so the order
lifecycle can record the attempt and continue.
""" """
config = XeroConfig.from_env() config = XeroConfig.from_env()
payload = build_invoice_payload(order, customer) payload = build_invoice_payload(order, customer, link)
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}" summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
if not config.configured: if not config.configured:
+2 -1
View File
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "data-entry-app-backend" name = "data-entry-app-backend"
version = "0.1.14" version = "0.1.19"
description = "Costing platform MVP backend" description = "Costing platform MVP backend"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"fastapi>=0.115,<1.0", "fastapi>=0.115,<1.0",
"python-multipart>=0.0.9,<1.0",
"openpyxl>=3.1,<4.0", "openpyxl>=3.1,<4.0",
"rich>=13.9,<15.0", "rich>=13.9,<15.0",
"uvicorn[standard]>=0.30,<1.0", "uvicorn[standard]>=0.30,<1.0",
+102
View File
@@ -0,0 +1,102 @@
"""The Mix Editor must resolve and edit the SAME formula the Mix Calculator reads.
These cover `resolve_editor_mix_formula` / `resolve_representative_product`: a mix
whose product carries its own formula resolves to that product (not the shared
mix master), percentages are computed against the total, and the representative
product is chosen the way the calculator chooses it.
"""
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.db.session import Base
from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial
from app.services.mix_calculator_service import (
resolve_editor_mix_formula,
resolve_representative_product,
)
TENANT = "hunter-premium-produce"
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _raw(db: Session, name: str) -> RawMaterial:
material = RawMaterial(tenant_id=TENANT, name=name, unit_of_measure="kg", kg_per_unit=1, status="active")
db.add(material)
db.flush()
return material
def test_resolves_product_formula_not_mix_master():
db = _session()
bayley = _raw(db, "Bayley")
filler = _raw(db, "Filler")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Pigeon Mix")
db.add(mix)
db.flush()
# Shared mix master says something different from the product formula.
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=bayley.id, quantity_kg=100))
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=filler.id, quantity_kg=100))
product = Product(tenant_id=TENANT, client_name="Hunter", name="Pigeon 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
db.add(product)
db.flush()
# The calculator's real numbers live here: 787.5 / 1320.41 ~ 59.6%.
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=787.5, sort_order=1))
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=532.91, sort_order=2))
db.commit()
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
assert formula["source"] == "product"
assert formula["product_id"] == product.id
assert formula["total_kg"] == 1320.41
by_name = {row["raw_material_name"]: row for row in formula["ingredients"]}
assert by_name["Bayley"]["quantity_kg"] == 787.5
# Percentage matches the worked example (787.5 / 1320.41 * 100).
assert abs(by_name["Bayley"]["mix_percentage"] - 59.6406) < 0.001
def test_falls_back_to_mix_master_when_no_product_formula():
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
db.add(mix)
db.flush()
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
db.commit()
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
assert formula["source"] == "mix"
assert formula["total_kg"] == 50
assert formula["ingredients"][0]["mix_percentage"] == 100.0
def test_representative_product_prefers_20kg_bag():
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Dual Mix")
db.add(mix)
db.flush()
bulka = Product(tenant_id=TENANT, client_name="Hunter", name="Dual Bulka", mix_id=mix.id, unit_of_measure="500kg bulka", visible=True)
bag = Product(tenant_id=TENANT, client_name="Hunter", name="Dual 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
db.add_all([bulka, bag])
db.flush()
for product in (bulka, bag):
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=maize.id, quantity_kg=20, sort_order=1))
db.commit()
representative = resolve_representative_product(db, tenant_id=TENANT, mix_id=mix.id)
assert representative is not None
assert representative.unit_of_measure == "20kg bag"
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the read-only external data API (`/api/v1`) used by Power BI.
Drives the real FastAPI app via TestClient against an in-memory database, and
swaps the configured API key in by replacing the module-level `settings` object
(the real one is a frozen dataclass).
"""
from __future__ import annotations
from dataclasses import replace
from datetime import date
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api import public_v1
from app.core.config import settings
from app.db.session import Base, get_db
from app.main import app
from app.models.throughput import ProductionThroughput
API_KEY = "test-powerbi-key"
TENANT = "hunter-premium-produce"
def _seed_entry(db, *, tenant_id: str, product: str, quantity: float, when: date) -> None:
db.add(
ProductionThroughput(
tenant_id=tenant_id,
production_date=when,
product_name_snapshot=product,
bag_size=20,
quantity=quantity,
quantity_type="bags",
calculated_kg=quantity * 20,
created_by="test",
)
)
@pytest.fixture()
def db_factory():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def override_get_db():
db = TestingSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
yield TestingSession
app.dependency_overrides.clear()
def _make_client(monkeypatch, *, api_key: str) -> TestClient:
# Replace the whole settings object the router reads (frozen dataclass).
monkeypatch.setattr(public_v1, "settings", replace(settings, powerbi_api_key=api_key, powerbi_tenant_id=TENANT))
return TestClient(app)
def test_disabled_when_key_unset(monkeypatch, db_factory):
client = _make_client(monkeypatch, api_key="")
response = client.get("/api/v1/throughput", headers={"X-API-Key": "anything"})
assert response.status_code == 503
def test_rejects_missing_and_wrong_key(monkeypatch, db_factory):
client = _make_client(monkeypatch, api_key=API_KEY)
assert client.get("/api/v1/throughput").status_code == 401
assert client.get("/api/v1/throughput", headers={"X-API-Key": "nope"}).status_code == 401
def test_returns_entries_for_tenant(monkeypatch, db_factory):
db = db_factory()
_seed_entry(db, tenant_id=TENANT, product="Maize", quantity=10, when=date(2026, 1, 5))
_seed_entry(db, tenant_id=TENANT, product="Barley", quantity=5, when=date(2026, 1, 6))
# An entry in another tenant must never leak through.
_seed_entry(db, tenant_id="someone-else", product="Secret", quantity=99, when=date(2026, 1, 7))
db.commit()
db.close()
client = _make_client(monkeypatch, api_key=API_KEY)
# Header auth.
response = client.get("/api/v1/throughput", headers={"X-API-Key": API_KEY})
assert response.status_code == 200
rows = response.json()
names = [row["product_name_snapshot"] for row in rows]
assert names == ["Maize", "Barley"] # oldest first, other tenant excluded
assert rows[0]["calculated_kg"] == 200.0
# Query-string auth works too (Power BI Web connector convenience).
assert client.get(f"/api/v1/throughput?api_key={API_KEY}").status_code == 200
def test_date_filter(monkeypatch, db_factory):
db = db_factory()
_seed_entry(db, tenant_id=TENANT, product="Old", quantity=1, when=date(2026, 1, 1))
_seed_entry(db, tenant_id=TENANT, product="New", quantity=1, when=date(2026, 2, 1))
db.commit()
db.close()
client = _make_client(monkeypatch, api_key=API_KEY)
response = client.get("/api/v1/throughput?date_from=2026-01-15", headers={"X-API-Key": API_KEY})
assert [row["product_name_snapshot"] for row in response.json()] == ["New"]
+108
View File
@@ -0,0 +1,108 @@
"""Regression guards for the print/PDF Content-Security-Policy.
The in-app print dialog loads a generated PDF as a same-origin ``blob:`` URL into
an iframe and calls ``contentWindow.print()``. If the CSP omits ``frame-src`` /
``child-src`` for ``blob:`` the directive falls back to ``default-src 'self'``,
which silently blocks the frame and breaks printing for every user (regardless of
role). These tests pin the policy on both layers that emit it:
* the FastAPI security middleware (covers every API response), and
* the production nginx config (the source of the *document* CSP that actually
governs ``frame-src`` in the browser).
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.core.config import settings
from app.core.security import issue_token
from app.main import app
REPO_ROOT = Path(__file__).resolve().parents[2]
NGINX_CONFIGS = [REPO_ROOT / "deploy" / "nginx" / "clients.lean-101.conf"]
# Directives the print flow depends on. blob: must be framable, and that must not
# come at the cost of dropping the same-origin baseline.
REQUIRED_FRAME_SOURCES = {"'self'", "blob:"}
def _parse_csp(header: str) -> dict[str, set[str]]:
"""Parse a CSP header string into ``{directive: {sources}}``."""
directives: dict[str, set[str]] = {}
for part in header.split(";"):
tokens = part.split()
if not tokens:
continue
directives[tokens[0].lower()] = set(tokens[1:])
return directives
def _assert_blob_framing(header: str) -> None:
csp = _parse_csp(header)
# frame-src must exist and allow self + blob (no falling back to default-src).
assert "frame-src" in csp, f"frame-src missing from CSP: {header!r}"
assert REQUIRED_FRAME_SOURCES <= csp["frame-src"], (
f"frame-src must allow {REQUIRED_FRAME_SOURCES}, got {csp['frame-src']}"
)
# child-src is the Safari fallback for frame-src; keep it aligned.
assert "child-src" in csp, f"child-src missing from CSP: {header!r}"
assert "blob:" in csp["child-src"], f"child-src must allow blob:, got {csp['child-src']}"
# We only widened framing: the same-origin default must stay intact.
assert csp.get("default-src") == {"'self'"}, f"default-src weakened: {csp.get('default-src')}"
# --- Backend middleware policy ------------------------------------------------
@pytest.fixture()
def client() -> TestClient:
with TestClient(app) as test_client:
yield test_client
def test_backend_csp_allows_blob_frames(client: TestClient) -> None:
response = client.get("/health")
assert "content-security-policy" in response.headers
_assert_blob_framing(response.headers["content-security-policy"])
def test_backend_csp_present_for_all_users(client: TestClient) -> None:
"""The policy is identical for anonymous, authenticated, and rejected (401)
requests, so printing can never depend on who is signed in."""
admin_token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
responses = [
client.get("/health"), # anonymous
client.get("/api/access/me"), # the endpoint that 401s for warehouse users
client.get("/api/access/me", headers={"Authorization": f"Bearer {admin_token}"}),
]
policies = set()
for response in responses:
header = response.headers.get("content-security-policy")
assert header is not None, f"CSP missing on {response.request.url} ({response.status_code})"
_assert_blob_framing(header)
policies.add(header)
assert len(policies) == 1, "CSP must not vary by authentication state"
# --- Production document policy (nginx) ---------------------------------------
def test_nginx_csp_allows_blob_frames() -> None:
"""Every CSP the production nginx emits must allow blob framing. This guards
the *document* policy, which is what the browser enforces for the print iframe."""
csp_line = re.compile(r'Content-Security-Policy\s+"([^"]+)"', re.IGNORECASE)
for config_path in NGINX_CONFIGS:
assert config_path.exists(), f"missing nginx config: {config_path}"
text = config_path.read_text(encoding="utf-8")
policies = csp_line.findall(text)
assert policies, f"no Content-Security-Policy header found in {config_path}"
for policy in policies:
_assert_blob_framing(policy)
+46
View File
@@ -13,6 +13,7 @@ from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.seed import seed_throughput_products_from_costing from app.seed import seed_throughput_products_from_costing
from app.services.throughput_service import ( from app.services.throughput_service import (
calculate_kg, calculate_kg,
import_entries_from_file,
import_names_sheet, import_names_sheet,
import_production_sheet, import_production_sheet,
normalise_staff_name, normalise_staff_name,
@@ -240,3 +241,48 @@ def test_seed_throughput_products_from_costing_updates_existing_by_item_id():
assert products[0].name == "Updated Wheat 25kg" assert products[0].name == "Updated Wheat 25kg"
assert products[0].default_bag_size == 25 assert products[0].default_bag_size == 25
assert products[0].active is True assert products[0].active is True
def test_upload_import_keeps_blank_destination_flags_false():
db = _session()
csv_bytes = (
"Date,Product,Quantity,Type,Bag Size,For Order,For Stock,Job Number\n"
"2026-06-12,Specialty Pigeon Breeder,40,bags,20,,,\n"
).encode("utf-8")
result = import_entries_from_file(
db,
filename="throughput-import.csv",
content=csv_bytes,
tenant_id="test-tenant",
created_by="tester@example.com",
)
assert result["entries_imported"] == 1
entry = db.scalar(select(ProductionThroughput))
assert entry is not None
assert entry.for_order is False
assert entry.for_stock is False
assert entry.job_number is None
def test_upload_import_does_not_treat_unknown_destination_text_as_true():
db = _session()
csv_bytes = (
"Date,Product,Quantity,Type,Bag Size,For Order,For Stock\n"
"2026-06-12,Specialty Pigeon Breeder,40,bags,20,stock,\n"
).encode("utf-8")
result = import_entries_from_file(
db,
filename="throughput-import.csv",
content=csv_bytes,
tenant_id="test-tenant",
created_by="tester@example.com",
)
assert result["entries_imported"] == 1
entry = db.scalar(select(ProductionThroughput))
assert entry is not None
assert entry.for_order is False
assert entry.for_stock is False
+15
View File
@@ -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 = @"
+11 -1
View File
@@ -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;
+3
View File
@@ -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
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.12", "version": "0.1.26",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.12", "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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.14", "version": "0.1.26",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+100
View File
@@ -0,0 +1,100 @@
/**
* Modern hover/focus tooltip action.
*
* Renders a styled bubble appended to <body> with fixed positioning, so it is
* never clipped by an overflow:hidden ancestor (e.g. the topbar). Themes via the
* shared design tokens see `.app-tooltip` in styles/theme.css.
*
* Usage: <button use:tooltip={'Switch to dark mode'}></button>
* <button use:tooltip={{ label: 'Whats new', placement: 'bottom' }}></button>
*/
type Placement = 'top' | 'bottom';
type TooltipOptions = string | { label: string; placement?: Placement; delay?: number };
const GAP = 8;
const DEFAULT_DELAY = 300;
function normalize(options: TooltipOptions): { label: string; placement: Placement; delay: number } {
if (typeof options === 'string') {
return { label: options, placement: 'bottom', delay: DEFAULT_DELAY };
}
return {
label: options.label,
placement: options.placement ?? 'bottom',
delay: options.delay ?? DEFAULT_DELAY
};
}
export function tooltip(node: HTMLElement, options: TooltipOptions) {
let current = normalize(options);
let bubble: HTMLDivElement | null = null;
let showTimer: ReturnType<typeof setTimeout> | null = null;
function position() {
if (!bubble) return;
const anchor = node.getBoundingClientRect();
const tip = bubble.getBoundingClientRect();
let left = anchor.left + anchor.width / 2 - tip.width / 2;
left = Math.max(GAP, Math.min(left, window.innerWidth - tip.width - GAP));
const top =
current.placement === 'top'
? anchor.top - tip.height - GAP
: anchor.bottom + GAP;
bubble.style.left = `${Math.round(left)}px`;
bubble.style.top = `${Math.round(top)}px`;
}
function show() {
if (bubble || !current.label) return;
bubble = document.createElement('div');
bubble.className = 'app-tooltip';
bubble.setAttribute('role', 'tooltip');
bubble.dataset.placement = current.placement;
bubble.textContent = current.label;
document.body.appendChild(bubble);
position();
// Trigger the fade/translate transition on the next frame.
requestAnimationFrame(() => bubble?.classList.add('is-visible'));
}
function scheduleShow() {
clearTimeout(showTimer ?? undefined);
showTimer = setTimeout(show, current.delay);
}
function hide() {
clearTimeout(showTimer ?? undefined);
showTimer = null;
bubble?.remove();
bubble = null;
}
node.addEventListener('mouseenter', scheduleShow);
node.addEventListener('mouseleave', hide);
node.addEventListener('focus', show);
node.addEventListener('blur', hide);
node.addEventListener('click', hide);
return {
update(next: TooltipOptions) {
current = normalize(next);
if (bubble) {
bubble.textContent = current.label;
bubble.dataset.placement = current.placement;
position();
}
},
destroy() {
hide();
node.removeEventListener('mouseenter', scheduleShow);
node.removeEventListener('mouseleave', hide);
node.removeEventListener('focus', show);
node.removeEventListener('blur', hide);
node.removeEventListener('click', hide);
}
};
}
+126 -2
View File
@@ -8,7 +8,15 @@ import type {
ClientUserModulePermission, ClientUserModulePermission,
ClientUserUpdateInput, ClientUserUpdateInput,
LoginResponse, LoginResponse,
EditorMixCreateInput,
EditorMixUpdateInput, EditorMixUpdateInput,
EditorMixRow,
EditorMixFormula,
EditorResolvedMixFormula,
EditorMixFormulaRowInput,
EditorIngredientRow,
EditorIngredientCreateInput,
EditorIngredientUpdateInput,
EditorProductFormula, EditorProductFormula,
EditorProductRow, EditorProductRow,
EditorProductUpdateInput, EditorProductUpdateInput,
@@ -38,10 +46,14 @@ import type {
OrderingCustomerUser, OrderingCustomerUser,
OrderingNotificationSettings, OrderingNotificationSettings,
XeroStatus, XeroStatus,
XeroContactList,
XeroContactLinkRow,
Scenario, Scenario,
ThroughputEntry, ThroughputEntry,
ThroughputEntryCreateInput, ThroughputEntryCreateInput,
ThroughputEntryUpdateInput,
ThroughputEntryListParams, ThroughputEntryListParams,
ThroughputImportResult,
ThroughputProduct, ThroughputProduct,
ThroughputProductCreateInput, ThroughputProductCreateInput,
ThroughputProductUpdateInput ThroughputProductUpdateInput
@@ -250,6 +262,45 @@ async function request<T>(
} }
} }
// Multipart upload. Unlike `request`, we must NOT set Content-Type ourselves —
// the browser sets `multipart/form-data` with the correct boundary when given a
// FormData body. Mirrors `request`'s auth/cache/error handling otherwise.
async function uploadFile<T>(
path: string,
formData: FormData,
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<T> {
try {
const response = await fetcher(resolveRequestUrl(path, fetcher), {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!response.ok) {
let message = 'Request failed';
try {
const body = (await response.json()) as { detail?: string };
message = body.detail ?? message;
} catch {
message = response.statusText || message;
}
throw new Error(message);
}
if (browser) {
clearApiCache();
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
} catch (error) {
throw normalizeRequestError(error);
}
}
async function requestBlob( async function requestBlob(
path: string, path: string,
options: RequestInit = {}, options: RequestInit = {},
@@ -330,11 +381,50 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
editorMixes: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.q) search.set('q', params.q);
if (params?.client_name) search.set('client_name', params.client_name);
if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString();
const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes';
return cachedFetchJson<EditorMixRow[]>(path, 'client', fetcher);
},
createEditorMix: (payload: EditorMixCreateInput) =>
request<EditorMixRow>('/api/editor/mixes', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) => updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
request<EditorProductRow[]>(`/api/editor/mixes/${mixId}`, { request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
editorMixFormula: (mixId: number) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
// The resolved formula matching the Mix Calculator (product-first), used by
// the Mix Editor ingredient panel.
editorMixResolvedFormula: (mixId: number) =>
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {}, 'client'),
replaceEditorMixFormula: (mixId: number, rows: EditorMixFormulaRowInput[]) =>
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {
method: 'PUT',
body: JSON.stringify({ rows })
}, 'client'),
addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteEditorMixIngredient: (mixId: number, ingredientId: number) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'DELETE'
}, 'client'),
editorProductFormula: (productId: number) => editorProductFormula: (productId: number) =>
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {}, 'client'), request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {}, 'client'),
addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) => addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
@@ -351,6 +441,18 @@ export const api = {
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, { request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
method: 'DELETE' method: 'DELETE'
}, 'client'), }, 'client'),
editorIngredients: (fetcher?: ApiFetch) =>
cachedFetchJson<EditorIngredientRow[]>('/api/editor/ingredients', 'client', fetcher),
createEditorIngredient: (payload: EditorIngredientCreateInput) =>
request<EditorIngredientRow>('/api/editor/ingredients', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorIngredient: (ingredientId: number, payload: EditorIngredientUpdateInput) =>
request<EditorIngredientRow>(`/api/editor/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
productCosts: (fetcher?: ApiFetch) => productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher), cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
productCostingItems: (fetcher?: ApiFetch) => productCostingItems: (fetcher?: ApiFetch) =>
@@ -391,6 +493,18 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
updateThroughputEntry: (entryId: number, payload: ThroughputEntryUpdateInput) =>
request<ThroughputEntry>(`/api/throughput/entries/${entryId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteThroughputEntry: (entryId: number) =>
request<void>(`/api/throughput/entries/${entryId}`, { method: 'DELETE' }, 'client'),
importThroughputEntries: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return uploadFile<ThroughputImportResult>('/api/throughput/import', formData, 'client');
},
createThroughputProduct: (payload: ThroughputProductCreateInput) => createThroughputProduct: (payload: ThroughputProductCreateInput) =>
request<ThroughputProduct>('/api/throughput/products', { request<ThroughputProduct>('/api/throughput/products', {
method: 'POST', method: 'POST',
@@ -579,6 +693,16 @@ export const api = {
cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher), cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher),
updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) => updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) =>
request<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), request<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher) xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher),
xeroContacts: (fetcher?: ApiFetch) =>
cachedFetchJson<XeroContactList>('/api/ordering-admin/xero/contacts', 'client', fetcher),
xeroContactLinks: (fetcher?: ApiFetch) =>
cachedFetchJson<XeroContactLinkRow[]>('/api/ordering-admin/xero/contact-links', 'client', fetcher),
linkCustomerToXero: (
customerId: number,
payload: { xero_contact_id: string; xero_contact_name?: string | null; xero_contact_email?: string | null }
) => request(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
unlinkCustomerFromXero: (customerId: number) =>
request<void>(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'DELETE' }, 'client')
} }
}; };
+44 -6
View File
@@ -17,14 +17,43 @@ export type ChangelogEntry = {
export const APP_VERSION: string = packageInfo.version; export const APP_VERSION: string = packageInfo.version;
export const changelog: ChangelogEntry[] = [ export const changelog: ChangelogEntry[] = [
{
version: '0.1.22',
date: '2026-06-15',
highlights: [
'Web App - Throughput module is now live.'
]
},
{
version: '0.1.20',
date: '2026-06-13',
highlights: [
'App - Improvements',
'App - Bug fixes'
]
},
{
version: '0.1.18',
date: '2026-06-12',
highlights: [
'App - Improvements',
'App - Bug fixes'
]
},
{
version: '0.1.17',
date: '2026-06-12',
highlights: [
'App - Improvements',
'App - Mix Calculator bug fixes'
]
},
{ {
version: '0.1.14', version: '0.1.14',
date: '2026-06-11', date: '2026-06-11',
highlights: [ highlights: [
'New: private B2B customer ordering portal — customers browse their catalogue, see account-specific pricing, and submit orders.', 'Web App: Improved mix calculator',
'Order management console for internal staff: review orders, manage products, pricing, and the full order lifecycle.', 'Web App: Improved design'
'Customer-specific pricing engine (fixed, contract, price lists, tiered, and quote-only) calculated on the backend.',
'Order confirmations (PDF) and Xero submission, behind a clean integration layer.'
] ]
}, },
{ {
@@ -44,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;
+681
View File
@@ -0,0 +1,681 @@
<script lang="ts">
import { api } from '$lib/api';
import AuthGate from '$lib/components/AuthGate.svelte';
import WorkspaceBootCard from '$lib/components/app-shell/WorkspaceBootCard.svelte';
import WorkspaceSignedOutCard from '$lib/components/app-shell/WorkspaceSignedOutCard.svelte';
import WorkspaceTabletNav from '$lib/components/app-shell/WorkspaceTabletNav.svelte';
import WorkspaceAppsFab, { type WorkspaceFabItem } from '$lib/components/WorkspaceAppsFab.svelte';
import { PALETTE_RESULT_LIMIT, buildSessionKey, filterSearchItems } from '$lib/components/app-shell/utils';
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
import WorkspacePageHeader from '$lib/components/navigation/WorkspacePageHeader.svelte';
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
import { currentChangelog } from '$lib/changelog';
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
import { featureFlags } from '$lib/features';
import {
canCreateMixSession as sessionCanCreateMixSession,
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenClientAccess as sessionCanOpenClientAccess,
canOpenDashboard as sessionCanOpenDashboard,
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster,
canOpenCustomerOrdering as sessionCanOpenCustomerOrdering,
canManageOrdering as sessionCanManageOrdering,
canOpenProductCosting as sessionCanOpenProductCosting,
canOpenReporting as sessionCanOpenReporting,
canOpenSettings as sessionCanOpenSettings,
canOpenThroughput as sessionCanOpenThroughput,
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
getWorkspaceRole,
getWorkspaceHomeHref as sessionWorkspaceHomeHref,
isWorkspaceRouteAllowed
} from '$lib/workspace-access';
import {
accessControlItem,
baseSearchItems,
buildClientNavEntries,
dashboardItem,
editorItem,
ingredientsEditorItem,
footerLinks,
mixCalculatorItem,
orderingItem,
orderingManageChildren,
orderingManageGroup,
pageMeta,
productCostingItem,
reportingItem,
throughputItem,
type FooterLink,
type NavEntry,
type SearchItem,
type NavItem,
workingDocumentItems
} from '$lib/navigation/client-navigation';
import { onMount } from 'svelte';
import packageInfo from '../../../package.json';
let { children } = $props();
const isRootRoute = $derived(page.url.pathname === '/');
let searchOpen = $state(false);
let searchQuery = $state('');
let searchFocusRequest = $state(0);
let appsFabOpen = $state(false);
let sidebarOpen = $state(true);
let userMenuOpen = $state(false);
let navOpen = $state(false);
let showBottomNav = $state(false);
let whatsNewOpen = $state(false);
// The user identity we've already run the "what's new" check for this mount,
// so the dialog is evaluated once per login rather than on every navigation.
let whatsNewCheckedFor = $state<string | null>(null);
let isRestoringSession = $state(false);
let restoredSessionKey = $state<string | null>(null);
let seededSearchItems = $state<SearchItem[]>([]);
let seededSearchKey = $state<string | null>(null);
let bootDelayDone = $state(false);
let sidebarStateReady = $state(false);
const SIDEBAR_STORAGE_KEY = 'hsf:shell:sidebar-open';
const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear();
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
const shellPageMeta = $derived(
routeGuardPending
? { title: 'Loading Workspace', category: 'Workspace', icon: dashboardItem.icon }
: pageMeta(page.url.pathname)
);
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
const visibleWorkingDocumentItems = $derived(
!$clientSession
? workingDocumentItems
: workingDocumentItems.filter((item) => {
if (item.href === '/mixes') return canOpenMixMaster;
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
})
);
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
// Ordering serves two audiences: internal staff get the management console
// (/ordering/manage), customers get the catalogue (/ordering).
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
// Internal staff get the collapsible "Order Management" family (queue +
// products/customers/pricing/settings/integrations); customers get the single
// catalogue link. Either way it becomes one NavEntry the rail can render.
const visibleOrderingEntry = $derived<NavEntry | null>(
canManageOrdering
? { kind: 'group', group: orderingManageGroup }
: canOpenCustomerOrdering
? { kind: 'item', item: orderingItem }
: null
);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (costing
// tools plus throughput), then the standalone ordering/insights modules. Built
// from the same access-filtered items, so a role only ever sees the families it
// may open.
const navEntries = $derived(
buildClientNavEntries({
dashboard: visibleDashboardItem,
costing: [
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
...visibleWorkingDocumentItems
],
throughput: visibleThroughputItem,
ordering: visibleOrderingEntry,
reporting: visibleReportingItem
})
);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([
...(!isOperationsUser ? footerLinks : [])
] as FooterLink[]);
const fabItems = $derived.by(() => {
const items = [
visibleDashboardItem,
...visibleWorkingDocumentItems,
visibleMixCalculatorItem,
visibleProductCostingItem,
visibleThroughputItem,
visibleOrderingEntry?.kind === 'item'
? visibleOrderingEntry.item
: visibleOrderingEntry?.group
? {
href: visibleOrderingEntry.group.href ?? '/ordering/manage',
label: visibleOrderingEntry.group.label,
icon: visibleOrderingEntry.group.icon
}
: null,
visibleReportingItem,
visibleEditorItem,
visibleIngredientsEditorItem,
visibleAccessControlItem
].filter((item): item is { href: string; label: string; icon: WorkspaceFabItem['icon'] } => Boolean(item));
const seen = new Set<string>();
return items.flatMap((item) => {
if (seen.has(item.href)) {
return [];
}
seen.add(item.href);
return [{ href: item.href, label: item.label, icon: item.icon } satisfies WorkspaceFabItem];
});
});
const primaryBottomNavigation = $derived(
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...visibleWorkingDocumentItems.slice(0, 2)
]
);
const workingDocumentsActive = $derived(
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
);
const visibleBaseSearchItems = $derived(
baseSearchItems.filter((item) => {
if (item.href === '/') return canOpenDashboard;
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/mixes/new') return canCreateMixWorksheet;
if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession);
if (item.href === '/editor') return canOpenEditor;
if (item.href === '/ingredients') return canOpenEditor;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings;
return true;
})
);
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone));
const showDesktopSidebar = $derived(!showBottomNav);
function restoreSidebarState() {
if (typeof window === 'undefined') {
return true;
}
try {
return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) !== 'false';
} catch {
return true;
}
}
function persistSidebarState() {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarOpen));
} catch {
// Storage failures should not block shell interactions.
}
}
function openSearch(query = '') {
searchQuery = query;
searchOpen = true;
searchFocusRequest += 1;
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
}
function syncViewport() {
showBottomNav = window.innerWidth <= 1180;
if (!showBottomNav) {
navOpen = false;
}
}
async function runSearchItem(item: SearchItem) {
searchOpen = false;
searchQuery = '';
await goto(item.href);
}
async function openSettings() {
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
await goto('/settings');
}
async function signOut() {
try {
if ($clientSession?.role === 'internal') {
await api.internalLogout();
} else {
await api.clientLogout();
}
} catch {
// Clearing the local session remains the safe fallback.
} finally {
clientSession.clear();
}
}
const searchState = $derived(filterSearchItems(searchItems, searchQuery, PALETTE_RESULT_LIMIT));
$effect(() => {
page.url.pathname;
appsFabOpen = false;
userMenuOpen = false;
searchOpen = false;
searchQuery = '';
navOpen = false;
});
$effect(() => {
if (!sidebarStateReady) {
return;
}
sidebarOpen;
persistSidebarState();
});
$effect(() => {
const hydrated = $sessionHydrated;
const sessionKey = buildSessionKey($clientSession);
if (!hydrated) {
return;
}
if (!sessionKey) {
isRestoringSession = false;
restoredSessionKey = null;
return;
}
if (restoredSessionKey === sessionKey) {
return;
}
restoredSessionKey = sessionKey;
isRestoringSession = true;
// Internal Hunter Stock Feeds users are refreshed against /api/access/me;
// legacy client-portal users keep using /api/auth/client/session.
const refresh = $clientSession?.role === 'internal' ? api.internalSession() : api.clientSession();
refresh
.then((session) => {
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredSessionKey = null;
clientSession.clear();
})
.finally(() => {
isRestoringSession = false;
});
});
// Search items are seeded lazily — three list endpoints worth of
// data only when the user actually opens the search, not on every login or
// navigation. Subsequent opens hit the api.ts cache.
$effect(() => {
const hydrated = $sessionHydrated;
const session = $clientSession;
const sessionKey = buildSessionKey(session);
const shouldSeed = searchOpen;
if (!hydrated || !session || !sessionKey) {
seededSearchItems = [];
seededSearchKey = null;
return;
}
if (!shouldSeed || seededSearchKey === sessionKey) {
return;
}
seededSearchKey = sessionKey;
Promise.all([
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
? api.mixCalculatorSessions()
: Promise.resolve([])
])
.then(([mixes, sessions]) => {
if (seededSearchKey !== sessionKey) {
return;
}
seededSearchItems = [
...mixes.map((mix) => ({
href: `/mixes/${mix.id}`,
label: mix.name,
description: `Mix · ${mix.client_name} · ${mix.total_mix_kg}kg`,
keywords: `mix ${mix.name} ${mix.client_name} ${mix.notes ?? ''} ${mix.ingredients.map((ingredient) => ingredient.raw_material_name).join(' ')}`
})),
...sessions.map((savedSession) => ({
href: `/mix-calculator/${savedSession.id}`,
label: `${savedSession.session_number} · ${savedSession.product_name}`,
description: `Mix Session · ${savedSession.prepared_by_name} · ${savedSession.mix_date}`,
keywords: `mix calculator session ${savedSession.session_number} ${savedSession.product_name} ${savedSession.mix_name} ${savedSession.client_name} ${savedSession.prepared_by_name} ${savedSession.notes ?? ''}`
}))
];
})
.catch(() => {
if (seededSearchKey === sessionKey) {
seededSearchItems = [];
}
});
});
$effect(() => {
if ($sessionHydrated && !$clientSession && !isRootRoute) {
goto('/', { replaceState: true });
}
});
$effect(() => {
if (!$sessionHydrated || !$clientSession) {
return;
}
if (currentRouteAllowed || page.url.pathname === workspaceHomeHref) {
return;
}
goto(workspaceHomeHref, { replaceState: true });
});
// Surface the release notes once per version per user, right after login.
// hasSeenVersion keeps this to a single appearance: once dismissed (which
// records the version), it won't return until the next version ships.
$effect(() => {
if (!$sessionHydrated || !$clientSession || !currentChangelog) {
return;
}
const userKey = buildSessionKey($clientSession);
if (!userKey) {
return;
}
if (whatsNewCheckedFor === userKey) {
return;
}
whatsNewCheckedFor = userKey;
if (!hasSeenVersion(userKey, currentChangelog.version)) {
whatsNewOpen = true;
}
});
function dismissWhatsNew() {
const userKey = buildSessionKey($clientSession);
if (userKey && currentChangelog) {
markVersionSeen(userKey, currentChangelog.version);
}
whatsNewOpen = false;
}
onMount(() => {
sidebarOpen = restoreSidebarState();
sidebarStateReady = true;
const bootTimer = window.setTimeout(() => {
bootDelayDone = true;
}, 1500);
syncViewport();
const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const isTypingField =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target?.isContentEditable;
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
event.preventDefault();
openSearch();
}
if (event.key === 'Escape') {
searchOpen = false;
appsFabOpen = false;
userMenuOpen = false;
navOpen = false;
}
};
window.addEventListener('keydown', handleKeydown);
window.addEventListener('resize', syncViewport);
return () => {
window.clearTimeout(bootTimer);
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('resize', syncViewport);
};
});
const userInitials = $derived(
($clientSession?.name ?? '')
.split(' ')
.slice(0, 2)
.map((w: string) => w[0])
.join('')
.toUpperCase() || '?'
);
</script>
<svelte:head>
<title>{shellPageMeta.title} | Hunter Premium Produce</title>
</svelte:head>
{#if !$clientSession}
<div class="signed-out-shell">
{#if isRootRoute}
{@render children()}
{:else if showWorkspaceBoot}
<WorkspaceBootCard />
{:else}
<WorkspaceSignedOutCard />
{/if}
</div>
{:else}
<div class:sidebar-collapsed={!sidebarOpen && showDesktopSidebar} class="app-shell">
{#if showDesktopSidebar}
<ClientPrimaryRail
collapsed={!sidebarOpen}
currentPath={shellPathname}
entries={navEntries}
brandHref={workspaceHomeHref}
footerItems={visibleFooterLinks}
{appVersion}
{currentYear}
{canOpenSettings}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
{/if}
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
<ClientTopbar
sessionHydrated={$sessionHydrated}
session={$clientSession}
showSidebarToggle={!showBottomNav}
{sidebarOpen}
{userInitials}
{userMenuOpen}
{canUseWorkspaceSearch}
bind:searchQuery={searchQuery}
bind:searchOpen={searchOpen}
searchFocusRequest={searchFocusRequest}
filteredSearchItems={searchState.filteredItems}
hiddenResultCount={searchState.hiddenResultCount}
{canOpenSettings}
onRunSearchItem={runSearchItem}
onToggleSidebar={() => (sidebarOpen = !sidebarOpen)}
onToggleUserMenu={() => {
userMenuOpen = !userMenuOpen;
appsFabOpen = false;
}}
onOpenSettings={openSettings}
onSignOut={signOut}
onShowWhatsNew={() => (whatsNewOpen = true)}
/>
<main class="content">
{#if !routeGuardPending}
<WorkspacePageHeader
category={shellPageMeta.category}
title={shellPageMeta.title}
icon={shellPageMeta.icon}
/>
{/if}
<AuthGate
blocked={routeGuardPending}
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
title={isRestoringSession ? 'Restoring your client workspace.' : 'Routing you to an authorised page.'}
detail={
isRestoringSession
? 'Refreshing the saved session before rendering workspace content.'
: `The ${workspaceRole} role cannot open this route, so the workspace is redirecting before any page content mounts.`
}
>
{@render children()}
</AuthGate>
</main>
</div>
<WorkspaceAppsFab bind:open={appsFabOpen} items={fabItems} />
</div>
<WorkspaceTabletNav
bind:navOpen
{showBottomNav}
{primaryBottomNavigation}
{visibleDashboardItem}
{visibleMixCalculatorItem}
{visibleProductCostingItem}
{visibleThroughputItem}
{visibleEditorItem}
{visibleReportingItem}
{visibleWorkingDocumentItems}
{visibleFooterLinks}
{orderingManageGroup}
{orderingManageChildren}
{orderingItem}
{canManageOrdering}
{canOpenCustomerOrdering}
{canCreateMixWorksheet}
{canCreateMixSession}
{canOpenSettings}
pagePath={page.url.pathname}
onOpenSettings={openSettings}
onSignOut={signOut}
/>
{/if}
{#if $clientSession && whatsNewOpen && currentChangelog}
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
{/if}
<style>
.app-shell {
display: grid;
grid-template-columns: 252px minmax(0, 1fr);
min-height: 100vh;
background: var(--color-bg-app);
}
.app-shell.sidebar-collapsed {
grid-template-columns: 4.5rem minmax(0, 1fr);
}
.signed-out-shell {
min-height: 100vh;
padding: 1.5rem;
}
.main-shell {
min-width: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
overflow: hidden;
background: var(--color-bg-app);
}
.content {
--content-padding: 1.34rem;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
padding: var(--content-padding);
overflow: auto;
background: var(--color-bg-app);
}
.main-shell.bottom-nav-layout .content {
padding-bottom: 7.25rem;
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 1fr;
}
.content {
--content-padding: 1rem;
padding: 1rem;
}
}
@media (min-width: 1181px) {
.bottom-nav-layout .content {
padding-bottom: 1.34rem;
}
}
@media (max-width: 720px) {
.content {
--content-padding: 0.92rem;
padding: 0.92rem;
}
}
</style>
+114 -11
View File
@@ -9,43 +9,146 @@
</script> </script>
{#if blocked} {#if blocked}
<section class="auth-gate-card"> <div class="auth-gate-screen">
<p class="auth-gate-label">{label}</p> <section class="auth-gate-card" role="status" aria-live="polite">
<h2>{title}</h2> <div class="auth-gate-loader" aria-hidden="true">
<p>{detail}</p> <span class="ring"></span>
</section> <span class="ring ring-2"></span>
<span class="dot"></span>
</div>
<p class="auth-gate-label">{label}</p>
<h2>{title}</h2>
<p class="auth-gate-detail">{detail}</p>
</section>
</div>
{:else} {:else}
{@render children()} {@render children()}
{/if} {/if}
<style> <style>
.auth-gate-screen {
display: grid;
place-items: center;
width: 100%;
min-height: 100%;
height: 100%;
padding: 2rem;
}
.auth-gate-card { .auth-gate-card {
display: grid; display: grid;
justify-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 1.35rem 1.4rem; width: min(26rem, 100%);
padding: 2.1rem 2rem 2.25rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 1rem; border-radius: 1.25rem;
background: var(--panel); background: var(--panel);
text-align: center;
box-shadow: 0 18px 50px -24px rgba(0, 0, 0, 0.4);
animation: auth-gate-in 380ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* ── Animated loader ───────────────────────────────────────── */
.auth-gate-loader {
position: relative;
width: 3.1rem;
height: 3.1rem;
margin-bottom: 0.65rem;
}
.auth-gate-loader .ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2.5px solid transparent;
border-top-color: var(--color-brand, #2f9e6f);
border-right-color: color-mix(in srgb, var(--color-brand, #2f9e6f) 45%, transparent);
animation: auth-gate-spin 0.85s linear infinite;
}
.auth-gate-loader .ring-2 {
inset: 0.42rem;
border-top-color: color-mix(in srgb, var(--color-brand, #2f9e6f) 60%, transparent);
border-right-color: transparent;
animation-duration: 1.25s;
animation-direction: reverse;
}
.auth-gate-loader .dot {
position: absolute;
inset: 0;
margin: auto;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--color-brand, #2f9e6f);
animation: auth-gate-pulse 1.1s ease-in-out infinite;
} }
.auth-gate-label { .auth-gate-label {
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
font-size: 0.76rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
} }
.auth-gate-card h2 { .auth-gate-card h2 {
margin: 0; margin: 0;
font-size: 1.18rem; font-size: 1.2rem;
} }
.auth-gate-card p:last-child { .auth-gate-detail {
margin: 0; margin: 0;
max-width: 22rem;
color: var(--muted); color: var(--muted);
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.55; line-height: 1.55;
} }
@keyframes auth-gate-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes auth-gate-spin {
to {
transform: rotate(360deg);
}
}
@keyframes auth-gate-pulse {
0%,
100% {
transform: scale(0.7);
opacity: 0.55;
}
50% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.auth-gate-card {
animation: none;
}
.auth-gate-loader .ring,
.auth-gate-loader .ring-2 {
animation-duration: 1.6s;
}
.auth-gate-loader .dot {
animation: none;
}
}
</style> </style>
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -103,7 +103,7 @@
<td> <td>
<strong>{line.raw_material_name}</strong> <strong>{line.raw_material_name}</strong>
</td> </td>
<td>{formatNumber(line.required_kg, 2)}kg</td> <td>{formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -1,16 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Moon, Sun } from 'lucide-svelte'; import { Moon, Sun } from 'lucide-svelte';
import { resolvedTheme, toggleTheme } from '$lib/theme'; import { resolvedTheme, toggleTheme } from '$lib/theme';
import { tooltip } from '$lib/actions/tooltip';
const isDark = $derived($resolvedTheme === 'dark'); const isDark = $derived($resolvedTheme === 'dark');
const label = $derived(
isDark ? 'Switch to light mode' : 'Switch to dark mode'
);
</script> </script>
<button <button
class="theme-toggle" class="theme-toggle"
type="button" type="button"
onclick={toggleTheme} onclick={toggleTheme}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'} aria-label={label}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'} use:tooltip={label}
> >
{#if isDark} {#if isDark}
<Sun size={18} strokeWidth={1.75} /> <Sun size={18} strokeWidth={1.75} />
@@ -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>
@@ -0,0 +1,131 @@
<script lang="ts">
import { LoaderCircle } from 'lucide-svelte';
</script>
<section class="locked-card loading-card workspace-boot-card" aria-live="polite">
<div class="workspace-boot-orb" aria-hidden="true">
<LoaderCircle size={26} strokeWidth={2.1} />
</div>
<p class="workspace-label">Checking Workspace</p>
<h2>Restoring your client workspace.</h2>
<p>Hold on while we reload your saved session and bring the app back into place.</p>
<div class="workspace-boot-progress" aria-hidden="true">
<span class="workspace-boot-progress-bar"></span>
</div>
</section>
<style>
.locked-card {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.loading-card {
min-height: 10rem;
}
.workspace-boot-card {
position: relative;
display: grid;
gap: 0.85rem;
align-items: start;
min-height: 14rem;
margin: 0 auto;
padding: 1.45rem;
overflow: hidden;
}
.workspace-boot-card::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at top right, color-mix(in srgb, var(--color-brand) 18%, transparent), transparent 42%),
linear-gradient(180deg, color-mix(in srgb, var(--color-brand) 4%, transparent), transparent 45%);
pointer-events: none;
}
.workspace-boot-card > * {
position: relative;
z-index: 1;
}
.workspace-boot-orb {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.35rem;
height: 3.35rem;
border-radius: 1rem;
background: color-mix(in srgb, var(--color-brand) 12%, var(--color-bg-surface));
color: var(--color-brand);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-brand) 16%, transparent);
}
.workspace-boot-orb :global(svg) {
animation: workspace-spin 1s linear infinite;
}
.workspace-boot-progress {
width: min(16rem, 100%);
height: 0.42rem;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--color-brand) 10%, var(--color-border));
}
.workspace-boot-progress-bar {
display: block;
width: 38%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--color-brand), color-mix(in srgb, var(--color-brand) 62%, white));
animation: workspace-progress 1.15s ease-in-out infinite;
transform-origin: left center;
}
.workspace-label {
color: var(--muted);
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
h2,
p {
margin: 0;
}
h2 {
margin-top: 0.35rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
}
p:last-of-type {
margin-top: 0.45rem;
color: var(--muted);
}
@keyframes workspace-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes workspace-progress {
0% { transform: translateX(-110%) scaleX(0.85); }
55% { transform: translateX(135%) scaleX(1.05); }
100% { transform: translateX(280%) scaleX(0.9); }
}
@media (prefers-reduced-motion: reduce) {
.workspace-boot-orb :global(svg),
.workspace-boot-progress-bar {
animation: none;
}
}
</style>
@@ -0,0 +1,47 @@
<section class="locked-card loading-card signed-out-card">
<p class="workspace-label">Checking Session</p>
<h2>Returning to the client login screen.</h2>
<p>Only authenticated client users can open workspace routes directly.</p>
</section>
<style>
.locked-card {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.loading-card {
min-height: 10rem;
}
.signed-out-card {
margin: 0 auto;
}
.workspace-label {
color: var(--muted);
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
h2,
p {
margin: 0;
}
h2 {
margin-top: 0.35rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
}
p:last-of-type {
margin-top: 0.45rem;
color: var(--muted);
}
</style>
@@ -0,0 +1,499 @@
<script lang="ts">
import { Calculator, LogOut, Menu, Plus, Settings } from 'lucide-svelte';
import type { FooterLink, NavGroup, NavItem } from '$lib/navigation/client-navigation';
import { matchesRoute } from '$lib/navigation/client-navigation';
let {
showBottomNav,
navOpen = $bindable(false),
primaryBottomNavigation,
visibleDashboardItem,
visibleMixCalculatorItem,
visibleProductCostingItem,
visibleThroughputItem,
visibleEditorItem,
visibleReportingItem,
visibleWorkingDocumentItems,
visibleFooterLinks,
orderingManageGroup,
orderingManageChildren,
orderingItem,
canManageOrdering,
canOpenCustomerOrdering,
canCreateMixWorksheet,
canCreateMixSession,
canOpenSettings,
pagePath,
onOpenSettings,
onSignOut
}: {
showBottomNav: boolean;
navOpen?: boolean;
primaryBottomNavigation: NavItem[];
visibleDashboardItem: NavItem | null;
visibleMixCalculatorItem: NavItem | null;
visibleProductCostingItem: NavItem | null;
visibleThroughputItem: NavItem | null;
visibleEditorItem: NavItem | null;
visibleReportingItem: NavItem | null;
visibleWorkingDocumentItems: NavItem[];
visibleFooterLinks: FooterLink[];
orderingManageGroup: NavGroup;
orderingManageChildren: NavItem[];
orderingItem: NavItem;
canManageOrdering: boolean;
canOpenCustomerOrdering: boolean;
canCreateMixWorksheet: boolean;
canCreateMixSession: boolean;
canOpenSettings: boolean;
pagePath: string;
onOpenSettings: () => void | Promise<void>;
onSignOut: () => void | Promise<void>;
} = $props();
function closeDrawer() {
navOpen = false;
}
</script>
{#if showBottomNav}
{#if navOpen}
<button aria-label="Close navigation" class="nav-backdrop" type="button" onclick={closeDrawer}></button>
{/if}
<nav class="bottom-nav" aria-label="Tablet navigation">
{#each primaryBottomNavigation as item}
{@const Icon = item.icon}
<a class:active={matchesRoute(item.href, pagePath)} href={item.href}>
<span class="bottom-nav-icon"><Icon size={18} strokeWidth={1.85} /></span>
<span>{item.label}</span>
</a>
{/each}
<button aria-expanded={navOpen} class:active={navOpen} type="button" onclick={() => (navOpen = !navOpen)}>
<span class="bottom-nav-icon"><Menu size={18} strokeWidth={1.85} /></span>
<span>More</span>
</button>
</nav>
{#if navOpen}
<div aria-label="Tablet navigation drawer" class="bottom-drawer" role="dialog" aria-modal="true">
<div class="drawer-handle"></div>
<div class="drawer-header">
<div>
<p class="workspace-label">Workspace Drawer</p>
<strong>Hunter Premium Produce</strong>
</div>
<button aria-label="Close drawer" class="nav-toggle" type="button" onclick={closeDrawer}>
<span></span>
</button>
</div>
<div class="drawer-grid">
<nav class="drawer-section" aria-label="All workspace pages">
{#if visibleDashboardItem}
{@const Icon = visibleDashboardItem.icon}
<a class:active={matchesRoute(visibleDashboardItem.href, pagePath)} href={visibleDashboardItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleDashboardItem.label}</span>
</a>
{/if}
{#if visibleMixCalculatorItem}
{@const Icon = visibleMixCalculatorItem.icon}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, pagePath)} href={visibleMixCalculatorItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleMixCalculatorItem.label}</span>
</a>
{/if}
{#if visibleProductCostingItem}
{@const Icon = visibleProductCostingItem.icon}
<a class:active={matchesRoute(visibleProductCostingItem.href, pagePath)} href={visibleProductCostingItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleProductCostingItem.label}</span>
{#if visibleProductCostingItem.badge}<span class="drawer-badge">{visibleProductCostingItem.badge}</span>{/if}
</a>
{/if}
{#if visibleThroughputItem}
{@const Icon = visibleThroughputItem.icon}
<a class:active={matchesRoute(visibleThroughputItem.href, pagePath)} href={visibleThroughputItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleThroughputItem.label}</span>
{#if visibleThroughputItem.badge}<span class="drawer-badge">{visibleThroughputItem.badge}</span>{/if}
</a>
{/if}
{#if visibleEditorItem}
{@const Icon = visibleEditorItem.icon}
<a class:active={matchesRoute(visibleEditorItem.href, pagePath)} href={visibleEditorItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleEditorItem.label}</span>
{#if visibleEditorItem.badge}<span class="drawer-badge">{visibleEditorItem.badge}</span>{/if}
</a>
{/if}
{#if visibleReportingItem}
{@const Icon = visibleReportingItem.icon}
<a class:active={matchesRoute(visibleReportingItem.href, pagePath)} href={visibleReportingItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleReportingItem.label}</span>
</a>
{/if}
{#if canManageOrdering}
{@const GroupIcon = orderingManageGroup.icon}
<a class:active={pagePath === '/ordering/manage'} href="/ordering/manage" onclick={closeDrawer}>
<span class="nav-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span>{orderingManageGroup.label}</span>
</a>
<div class="drawer-sublist">
{#each orderingManageChildren as child}
{@const ChildIcon = child.icon}
<a class:active={matchesRoute(child.href, pagePath, child.exact)} href={child.href} onclick={closeDrawer}>
<span class="nav-icon"><ChildIcon size={18} strokeWidth={1.75} /></span>
<span>{child.label}</span>
</a>
{/each}
</div>
{:else if canOpenCustomerOrdering}
{@const Icon = orderingItem.icon}
<a class:active={matchesRoute(orderingItem.href, pagePath)} href={orderingItem.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{orderingItem.label}</span>
</a>
{/if}
{#if visibleWorkingDocumentItems.length}
<div class="drawer-sublist" id="drawer-working-documents-nav">
{#each visibleWorkingDocumentItems as item}
{@const Icon = item.icon}
<a class:active={matchesRoute(item.href, pagePath)} href={item.href} onclick={closeDrawer}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{item.label}</span>
</a>
{/each}
</div>
{/if}
</nav>
<div class="drawer-section drawer-actions">
{#if canCreateMixWorksheet}
<a href="/mixes/new" onclick={closeDrawer}>
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
<span>Create mix worksheet</span>
</a>
{/if}
{#if canCreateMixSession}
<a href="/mix-calculator" onclick={closeDrawer}>
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
<span>Create mix session</span>
</a>
{/if}
{#if canOpenSettings}
<button type="button" onclick={onOpenSettings}>
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
<span>Change settings</span>
</button>
{/if}
<button type="button" onclick={onSignOut}>
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
<span>Logout</span>
</button>
</div>
</div>
<div class="drawer-footer">
{#each visibleFooterLinks as item}
<a href={item.href} onclick={closeDrawer}>
<span>{item.label}</span>
<small>{item.shortLabel}</small>
</a>
{/each}
</div>
</div>
{/if}
{/if}
<style>
.nav-backdrop {
position: fixed;
inset: 0;
z-index: 48;
display: block;
border: none;
background: rgba(11, 18, 14, 0.28);
backdrop-filter: blur(4px);
}
.workspace-label {
color: var(--muted);
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.nav-toggle {
width: 2.05rem;
height: 2.05rem;
border: 1px solid var(--line);
border-radius: 0.68rem;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--panel);
color: var(--muted);
cursor: pointer;
}
.nav-toggle span,
.nav-toggle span::before,
.nav-toggle span::after {
width: 0.88rem;
height: 2px;
background: currentColor;
border-radius: 999px;
content: '';
}
.nav-toggle span {
position: relative;
display: inline-block;
}
.nav-toggle span::before,
.nav-toggle span::after {
position: absolute;
left: 0;
}
.nav-toggle span::before {
top: -0.28rem;
}
.nav-toggle span::after {
top: 0.28rem;
}
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--color-text-muted);
background: transparent;
border-radius: 0.55rem;
width: 1.6rem;
height: 1.6rem;
}
.bottom-nav,
.bottom-drawer {
display: none;
}
.drawer-sublist a {
position: relative;
}
@media (max-width: 1180px) {
.bottom-nav {
position: fixed;
left: max(0.8rem, env(safe-area-inset-left));
right: max(0.8rem, env(safe-area-inset-right));
bottom: max(0.8rem, env(safe-area-inset-bottom));
z-index: 45;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
padding: 0.6rem;
border: 1px solid var(--color-border);
border-radius: 1.35rem;
background: var(--color-bg-surface);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.bottom-nav a,
.bottom-nav button {
min-width: 0;
display: grid;
justify-items: center;
gap: 0.34rem;
padding: 0.62rem 0.38rem;
border: none;
border-radius: 1rem;
background: transparent;
color: var(--color-text-secondary);
text-align: center;
font-size: 0.74rem;
font-weight: 700;
cursor: pointer;
}
.bottom-nav a.active,
.bottom-nav button.active {
color: var(--color-brand);
background: var(--color-brand-tint);
}
.bottom-nav-icon {
width: 2.1rem;
height: 2.1rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.78rem;
color: var(--color-on-brand);
background: var(--color-brand);
font-size: 0.66rem;
letter-spacing: 0.04em;
}
.bottom-drawer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: grid;
gap: 1rem;
padding: 0.85rem 1rem calc(6.6rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
border-radius: 1.6rem 1.6rem 0 0;
background: var(--color-bg-surface);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
.drawer-handle {
width: 3.5rem;
height: 0.34rem;
margin: 0 auto;
border-radius: 999px;
background: var(--color-border);
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.drawer-header strong {
font-size: 1rem;
}
.drawer-grid {
display: grid;
gap: 0.9rem;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
}
.drawer-section {
display: grid;
gap: 0.4rem;
}
.drawer-section a,
.drawer-section button {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.82rem 0.86rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
}
.drawer-section a.active {
color: var(--color-brand-hover);
background: color-mix(in srgb, var(--color-brand) 11%, var(--color-bg-surface));
}
.drawer-badge {
margin-left: auto;
padding: 0.08rem 0.4rem;
border-radius: 999px;
background: var(--color-warning-tint);
color: var(--color-warning-text);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.drawer-sublist {
display: grid;
gap: 0.4rem;
}
.drawer-footer {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.drawer-footer a {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.82rem 0.9rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-weight: 600;
}
.drawer-footer small {
color: var(--muted);
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
}
@media (max-width: 900px) {
.drawer-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.bottom-nav {
left: max(0.55rem, env(safe-area-inset-left));
right: max(0.55rem, env(safe-area-inset-right));
bottom: max(0.55rem, env(safe-area-inset-bottom));
gap: 0.32rem;
padding: 0.45rem;
}
.bottom-nav a,
.bottom-nav button {
padding: 0.55rem 0.2rem;
font-size: 0.68rem;
}
.bottom-nav-icon {
width: 1.9rem;
height: 1.9rem;
font-size: 0.6rem;
}
.bottom-drawer {
padding: 0.75rem 0.8rem calc(6.3rem + env(safe-area-inset-bottom));
}
.drawer-footer {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,24 @@
import type { SearchItem } from '$lib/navigation/client-navigation';
import type { AppSession } from '$lib/session';
export const PALETTE_RESULT_LIMIT = 10;
export function buildSessionKey(session: AppSession | null | undefined): string | null {
if (!session) return null;
return `${session.role}:${session.email}:${session.user_id ?? ''}`;
}
export function filterSearchItems(items: SearchItem[], query: string, limit = PALETTE_RESULT_LIMIT) {
const trimmedQuery = query.trim().toLowerCase();
const matchingItems = items.filter((item) => {
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
return haystack.includes(trimmedQuery);
});
const filteredItems = matchingItems.slice(0, limit);
return {
matchingItems,
filteredItems,
hiddenResultCount: matchingItems.length - filteredItems.length
};
}
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { Calculator, Info, TriangleAlert, X } from 'lucide-svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { featureFlags } from '$lib/features'; import { featureFlags } from '$lib/features';
import { formatNumber } from '$lib/format'; import { formatNumber } from '$lib/format';
@@ -51,6 +52,9 @@
let batchSizeKg = $state(initialBatchSizeValue()); let batchSizeKg = $state(initialBatchSizeValue());
let preparedByName = $state(initialPreparedByNameValue()); let preparedByName = $state(initialPreparedByNameValue());
let notes = $state(initialNotesValue()); let notes = $state(initialNotesValue());
// Notes are optional and stay hidden behind "+ Add a note" until needed,
// matching the throughput composer. Auto-open when a saved session has one.
let showNote = $state(Boolean(initialSession?.notes));
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue()); let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
let formError = $state(''); let formError = $state('');
let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock mixes.'); let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock mixes.');
@@ -158,6 +162,12 @@
} }
} }
// Collapse the note field again, discarding anything typed.
function dismissNote() {
notes = '';
showNote = false;
}
function clearForm() { function clearForm() {
clientName = ''; clientName = '';
productId = 0; productId = 0;
@@ -165,6 +175,7 @@
batchSizeKg = ''; batchSizeKg = '';
preparedByName = $clientSession?.name ?? ''; preparedByName = $clientSession?.name ?? '';
notes = ''; notes = '';
showNote = false;
preview = null; preview = null;
formError = ''; formError = '';
formHint = 'Select a mix date and prepared by name, then choose a client to unlock mixes.'; formHint = 'Select a mix date and prepared by name, then choose a client to unlock mixes.';
@@ -287,11 +298,11 @@
{/if} {/if}
<section class="editor-grid"> <section class="editor-grid">
<article class="form-card"> <article class="composer">
<div class="section-header"> <div class="composer-head">
<div> <div class="composer-title">
<h3>Session Inputs</h3> <span class="composer-icon"><Calculator size={18} strokeWidth={2.2} /></span>
<p>Batch size drives the scale factor. Total bags are derived from the selected mix unit size.</p> <h2>Mix calculator</h2>
</div> </div>
{#if selectedProduct} {#if selectedProduct}
<div class="product-pill"> <div class="product-pill">
@@ -301,12 +312,13 @@
{/if} {/if}
</div> </div>
<div class="composer-body">
{#if formError} {#if formError}
<p class="message error">{formError}</p> <p class="message error"><TriangleAlert size={16} strokeWidth={2.2} /> <span>{formError}</span></p>
{/if} {/if}
{#if !formError && formHint} {#if !formError && formHint}
<p class="message hint">{formHint}</p> <p class="message hint"><Info size={16} strokeWidth={2.2} /> <span>{formHint}</span></p>
{/if} {/if}
<div class="field-grid"> <div class="field-grid">
@@ -350,11 +362,27 @@
<span>Batch size (kg)</span> <span>Batch size (kg)</span>
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="Batch size" type="number" /> <input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="Batch size" type="number" />
</label> </label>
</div>
<label class="full-width"> <div class="note-area">
<span>Notes</span> {#if showNote}
<textarea bind:value={notes} disabled={!canEdit} placeholder="Optional production notes or shift context" rows="4"></textarea> <div class="note-field">
</label> <textarea
class="note-input"
bind:value={notes}
disabled={!canEdit}
placeholder="Note (optional) — production notes or shift context"
rows="3"
></textarea>
{#if canEdit}
<button type="button" class="note-dismiss" title="Remove note" aria-label="Remove note" onclick={dismissNote}>
<X size={16} strokeWidth={2.4} />
</button>
{/if}
</div>
{:else if canEdit}
<button type="button" class="link-button" onclick={() => (showNote = true)}>+ Add a note</button>
{/if}
</div> </div>
{#if canEdit && selectedProduct} {#if canEdit && selectedProduct}
@@ -371,12 +399,13 @@
<span>{previewLoading ? 'Calculating...' : 'Calculate mix'}</span> <span>{previewLoading ? 'Calculating...' : 'Calculate mix'}</span>
</button> </button>
<button class="danger-button" disabled={previewLoading} type="button" onclick={clearForm}> <button class="secondary-button" disabled={previewLoading} type="button" onclick={clearForm}>
<span class="button-icon" style="--button-icon-url: url('/icons/trash.svg');" aria-hidden="true"></span> <span class="button-icon" style="--button-icon-url: url('/icons/trash.svg');" aria-hidden="true"></span>
<span>Clear</span> <span>Clear</span>
</button> </button>
</div> </div>
{/if} {/if}
</div>
</article> </article>
<MixCalculatorResultsPanel <MixCalculatorResultsPanel
@@ -400,13 +429,12 @@
<style> <style>
h2, h2,
h3,
p { p {
margin: 0; margin: 0;
} }
.eyebrow { .eyebrow {
color: #7d8d84; color: var(--color-text-muted);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -425,9 +453,8 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.section-header p,
.calculation-note span { .calculation-note span {
color: var(--muted); color: var(--color-text-secondary);
} }
.action-row { .action-row {
@@ -443,21 +470,68 @@
gap: 1rem; gap: 1rem;
} }
.form-card, /* The composer mirrors the throughput "Add a packing run" surface: a quiet
.locked-card { brand-tinted gradient panel that reads as a distinct, on-brand workspace.
border: 1px solid var(--line); Theme tokens keep it correct in dark mode. */
border-radius: 0.8rem; .composer {
background: var(--panel); border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
box-shadow: var(--shadow); border-radius: 0.9rem;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-brand) 7%, var(--color-bg-surface)),
color-mix(in srgb, var(--color-brand) 4%, var(--color-bg-surface))
);
/* Native selects open within the viewport, so the card can stay open for
a clean rounded edge without clipping anything. */
overflow: visible;
} }
.form-card, .composer-head {
.locked-card { display: flex;
padding: 1.2rem; align-items: center;
justify-content: space-between;
gap: 1rem 1.4rem;
flex-wrap: wrap;
padding: 1.3rem 1.45rem 1.05rem;
}
.composer-title {
display: inline-flex;
align-items: center;
gap: 0.6rem;
}
/* Brand-tinted badge echoing the throughput overview's section icon. */
.composer-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.composer-title h2 {
margin: 0;
font-size: clamp(1.25rem, 2vw, 1.7rem);
line-height: 1.05;
letter-spacing: -0.03em;
color: var(--color-text-primary);
}
.composer-body {
padding: 0 1.45rem 1.4rem;
} }
.locked-card { .locked-card {
max-width: 42rem; max-width: 42rem;
padding: 1.2rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-panel);
background: var(--color-bg-surface);
} }
.locked-card h2 { .locked-card h2 {
@@ -465,33 +539,28 @@
font-size: clamp(1.7rem, 3vw, 2.1rem); font-size: clamp(1.7rem, 3vw, 2.1rem);
} }
.section-header { /* Quick brand-tinted chip echoing the selected mix's unit, matching the pill
display: flex; language used across the app. */
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.section-header h3 {
font-size: 1.15rem;
font-weight: 700;
}
.product-pill { .product-pill {
display: grid; display: inline-flex;
gap: 0.14rem; align-items: baseline;
padding: 0.72rem 0.82rem; gap: 0.4rem;
border: 1px solid var(--line); padding: 0.4rem 0.78rem;
border-radius: 0.65rem; border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-border));
background: var(--panel-soft); border-radius: 999px;
background: var(--color-brand-tint);
color: var(--color-success-text);
}
.product-pill strong {
font-size: 0.95rem;
} }
.product-pill span { .product-pill span {
color: var(--muted); font-size: 0.76rem;
font-size: 0.78rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
opacity: 0.85;
} }
.field-grid { .field-grid {
@@ -510,67 +579,155 @@
font-weight: 600; font-weight: 600;
} }
.full-width { /* Optional note, revealed by "+ Add a note" — mirrors the throughput composer. */
grid-column: 1 / -1; .note-area {
margin-top: 0.9rem;
} }
input, .link-button {
select, padding: 0.25rem 0;
textarea { background: none;
border: 0;
color: var(--color-brand);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
.link-button:hover {
text-decoration: underline;
}
.note-field {
display: flex;
align-items: flex-start;
gap: 0.45rem;
}
.note-input {
flex: 1 1 auto;
min-width: 0;
}
.note-dismiss {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 48px;
height: 48px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.8rem;
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition:
border-color 140ms ease,
color 140ms ease,
background-color 140ms ease;
}
.note-dismiss:hover {
border-color: var(--color-text-muted);
color: var(--color-text-primary);
background: var(--color-bg-app);
}
.note-dismiss:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
/* White fields on the green tint, with brand-tinted borders and a 48px touch
target — the same input language as the throughput composer. */
.composer input,
.composer select,
.composer textarea {
width: 100%; width: 100%;
padding: 0.78rem 0.82rem; min-height: 48px;
border: 1px solid var(--line-strong); padding: 0.62rem 0.78rem;
border-radius: 0.6rem; border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
background: var(--color-input-bg); border-radius: 0.8rem;
color: var(--text); font-size: 0.98rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
transition: transition:
border-color 160ms ease, border-color 160ms ease,
box-shadow 160ms ease; box-shadow 160ms ease;
} }
input::placeholder, .composer input::placeholder,
textarea::placeholder { .composer textarea::placeholder {
color: var(--color-text-muted); color: var(--color-text-muted);
opacity: 1; opacity: 1;
} }
input:focus-visible, .composer input:focus-visible,
select:focus-visible, .composer select:focus-visible,
textarea:focus-visible { .composer textarea:focus-visible {
outline: none; outline: 3px solid var(--color-brand);
outline-offset: 1px;
border-color: var(--color-brand); border-color: var(--color-brand);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
} }
textarea { .composer textarea {
min-height: 6.5rem;
resize: vertical; resize: vertical;
} }
/* The Mix Name select stays locked until a client is chosen; make that state
unmistakable rather than looking like an empty, clickable field. */
.composer input:disabled,
.composer select:disabled,
.composer textarea:disabled {
background: color-mix(in srgb, var(--color-bg-app) 70%, var(--color-bg-surface));
border-color: var(--color-border);
color: var(--color-text-muted);
cursor: not-allowed;
}
.calculation-note { .calculation-note {
display: grid; display: grid;
gap: 0.2rem; gap: 0.2rem;
margin-top: 1rem; margin-top: 1rem;
padding: 0.92rem; padding: 0.92rem;
border-radius: 0.65rem; border: 1px solid var(--color-border);
background: var(--panel-soft); border-radius: var(--radius-row);
background: var(--color-bg-app);
} }
.message { .message {
display: flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
padding: 0.75rem 0.85rem; padding: 0.72rem 0.9rem;
border-radius: 0.6rem; border-radius: 0.7rem;
font-size: 0.9rem; font-size: 0.92rem;
font-weight: 500;
}
.message :global(svg) {
flex-shrink: 0;
} }
.message.error { .message.error {
background: #fff1f0; background: color-mix(in srgb, var(--color-error) 12%, var(--color-bg-surface));
color: #b2463f; color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
} }
/* Readable on-brand hint: a white-ish surface with a green info icon, instead
of low-contrast grey that washed out against the composer's tint. */
.message.hint { .message.hint {
background: var(--panel-soft); background: color-mix(in srgb, var(--color-brand) 16%, var(--color-bg-app));
color: var(--muted); color: var(--color-text-primary);
border: 1px solid var(--line); border: 1px solid color-mix(in srgb, var(--color-brand) 26%, var(--color-border));
}
.message.hint :global(svg) {
color: var(--color-brand);
} }
.action-row { .action-row {
@@ -578,15 +735,14 @@
} }
.primary-button, .primary-button,
.secondary-button, .secondary-button {
.danger-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.78rem 0.96rem; padding: 0.78rem 0.96rem;
border-radius: 0.6rem; border-radius: var(--radius-control);
border: 1px solid var(--line-strong); border: 1px solid var(--color-border);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: transition:
@@ -597,26 +753,29 @@
.primary-button { .primary-button {
border: none; border: none;
background: var(--color-brand); background: var(--color-brand);
color: #fff; color: var(--color-on-brand);
}
/* Composer buttons take the throughput composer's chunkier 48px shape. */
.composer .action-row .primary-button,
.composer .action-row .secondary-button {
min-height: 48px;
padding: 0.62rem 1.28rem;
border-radius: 0.8rem;
font-weight: 700;
} }
.secondary-button { .secondary-button {
background: #fff; background: var(--color-bg-surface);
color: #304038; color: var(--color-text-primary);
}
.danger-button {
background: #c63d32;
color: #fff;
border-color: #c63d32;
} }
.primary-button:hover:not(:disabled) { .primary-button:hover:not(:disabled) {
background: #126a33; background: var(--color-brand-hover);
} }
.primary-button:active:not(:disabled) { .primary-button:active:not(:disabled) {
background: #0f5a2b; background: var(--color-brand-hover);
} }
.secondary-button:hover:not(:disabled) { .secondary-button:hover:not(:disabled) {
@@ -624,14 +783,8 @@
background: var(--color-bg-app); background: var(--color-bg-app);
} }
.danger-button:hover:not(:disabled) {
background: #b2352b;
border-color: #b2352b;
}
.primary-button:focus-visible, .primary-button:focus-visible,
.secondary-button:focus-visible, .secondary-button:focus-visible {
.danger-button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent); outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -658,10 +811,6 @@
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.section-header {
flex-direction: column;
}
.field-grid { .field-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Download, Printer } from 'lucide-svelte'; import { ArrowUpDown, Download, Printer } from 'lucide-svelte';
import { formatDate, formatNumber } from '$lib/format'; import { formatDate, formatNumber } from '$lib/format';
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types'; import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
@@ -15,21 +15,48 @@
onDownloadPdf?: (() => void) | null; onDownloadPdf?: (() => void) | null;
} = $props(); } = $props();
// ── Ingredient sorting ──────────────────────────────────────────
// Default to heaviest ingredient first; clicking a header toggles direction
// (or switches column). Required kg starts descending, the name ascending.
type LineSortKey = 'raw_material_name' | 'required_kg';
let sortKey = $state<LineSortKey>('required_kg');
let sortDir = $state<'asc' | 'desc'>('desc');
function toggleSort(key: LineSortKey) {
if (sortKey === key) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
return;
}
sortKey = key;
sortDir = key === 'required_kg' ? 'desc' : 'asc';
}
function ariaSort(key: LineSortKey): 'ascending' | 'descending' | 'none' {
if (sortKey !== key) return 'none';
return sortDir === 'asc' ? 'ascending' : 'descending';
}
const sortedLines = $derived.by(() => {
const dir = sortDir === 'asc' ? 1 : -1;
return [...(preview?.lines ?? [])].sort((a, b) => {
const result =
sortKey === 'required_kg'
? (a.required_kg ?? 0) - (b.required_kg ?? 0)
: a.raw_material_name.localeCompare(b.raw_material_name, undefined, { sensitivity: 'base' });
return result * dir;
});
});
</script> </script>
<article class="result-card"> <article class="result-card">
<div class="section-header"> {#if sessionNumber}
<div> <div class="result-toolbar">
<h3>Calculated Output</h3>
<p>{preview ? 'Snapshot of the scaled raw material requirements.' : 'Run the calculation to preview the session output.'}</p>
</div>
{#if sessionNumber}
<div class="session-chip"> <div class="session-chip">
<span>Session</span> <span>Session</span>
<strong>{sessionNumber}</strong> <strong>{sessionNumber}</strong>
</div> </div>
{/if} </div>
</div> {/if}
{#if preview} {#if preview}
<div class="metric-row"> <div class="metric-row">
@@ -81,17 +108,37 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Raw material</th> <th aria-sort={ariaSort('raw_material_name')}>
<th>Required kg</th> <button
type="button"
class="sort-head"
class:active={sortKey === 'raw_material_name'}
onclick={() => toggleSort('raw_material_name')}
>
<span>Raw material</span>
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
</button>
</th>
<th aria-sort={ariaSort('required_kg')}>
<button
type="button"
class="sort-head"
class:active={sortKey === 'required_kg'}
onclick={() => toggleSort('required_kg')}
>
<span>Required kg</span>
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
</button>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each preview.lines as line} {#each sortedLines as line}
<tr> <tr>
<td data-label="Raw material"> <td data-label="Raw material">
<strong>{line.raw_material_name}</strong> <strong>{line.raw_material_name}</strong>
</td> </td>
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td> <td data-label="Required kg">{formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -135,55 +182,50 @@
</article> </article>
<style> <style>
h3,
p { p {
margin: 0; margin: 0;
} }
.section-header p,
.metric-card p, .metric-card p,
.summary-grid span, .summary-grid span,
.empty-state span { .empty-state span {
color: var(--muted); color: var(--color-text-muted);
} }
.result-card, .result-card,
.metric-card { .metric-card {
border: 1px solid var(--line); border: 1px solid var(--color-border);
border-radius: 0.8rem; background: var(--color-bg-surface);
background: var(--panel);
box-shadow: var(--shadow);
} }
.result-card { .result-card {
padding: 1.2rem; padding: 1.2rem;
border-radius: var(--radius-panel);
} }
.section-header { .metric-card {
border-radius: var(--radius-row);
}
.result-toolbar {
display: flex; display: flex;
align-items: flex-start; justify-content: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.section-header h3 {
font-size: 1.15rem;
font-weight: 700;
}
.session-chip { .session-chip {
display: grid; display: inline-flex;
gap: 0.14rem; align-items: baseline;
padding: 0.72rem 0.82rem; gap: 0.4rem;
border: 1px solid var(--line); padding: 0.4rem 0.78rem;
border-radius: 0.65rem; border: 1px solid var(--color-border);
background: var(--panel-soft); border-radius: 999px;
background: var(--color-bg-app);
} }
.session-chip span { .session-chip span {
color: var(--muted); color: var(--color-text-muted);
font-size: 0.78rem; font-size: 0.76rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
@@ -205,7 +247,7 @@
.metric-card span { .metric-card span {
display: block; display: block;
color: var(--muted); color: var(--color-text-muted);
font-size: 0.84rem; font-size: 0.84rem;
} }
@@ -220,9 +262,10 @@
gap: 0.45rem; gap: 0.45rem;
margin-top: 1rem; margin-top: 1rem;
padding: 0.92rem; padding: 0.92rem;
border-radius: 0.65rem; border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent);
background: #fdf6e9; border-radius: var(--radius-row);
color: #8a5a00; background: var(--color-warning-tint);
color: var(--color-warning-text);
} }
.summary-grid { .summary-grid {
@@ -232,9 +275,9 @@
.summary-grid div { .summary-grid div {
padding: 0.88rem 0.92rem; padding: 0.88rem 0.92rem;
border: 1px solid var(--line); border: 1px solid var(--color-border);
border-radius: 0.65rem; border-radius: var(--radius-row);
background: var(--panel-soft); background: var(--color-bg-app);
} }
.summary-grid span { .summary-grid span {
@@ -267,17 +310,53 @@
td { td {
padding: 0.9rem 0.85rem; padding: 0.9rem 0.85rem;
text-align: left; text-align: left;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--color-border);
} }
th { th {
color: var(--muted); color: var(--color-text-muted);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
} }
/* Clickable header: inherits the th look, adds a sort affordance. */
.sort-head {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
transition: color 140ms ease;
}
.sort-head :global(svg) {
opacity: 0.45;
transition: opacity 140ms ease;
}
.sort-head:hover,
.sort-head.active {
color: var(--color-text-primary);
}
.sort-head.active :global(svg) {
opacity: 1;
}
.sort-head:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
outline-offset: 2px;
border-radius: 0.3rem;
}
.primary-button, .primary-button,
.secondary-button { .secondary-button {
display: inline-flex; display: inline-flex;
@@ -285,8 +364,8 @@
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.78rem 0.96rem; padding: 0.78rem 0.96rem;
border-radius: 0.6rem; border-radius: var(--radius-control);
border: 1px solid var(--line-strong); border: 1px solid var(--color-border);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: transition:
@@ -297,20 +376,20 @@
.primary-button { .primary-button {
border: none; border: none;
background: var(--color-brand); background: var(--color-brand);
color: #fff; color: var(--color-on-brand);
} }
.secondary-button { .secondary-button {
background: #fff; background: var(--color-bg-surface);
color: #304038; color: var(--color-text-primary);
} }
.primary-button:hover:not(:disabled) { .primary-button:hover:not(:disabled) {
background: #126a33; background: var(--color-brand-hover);
} }
.primary-button:active:not(:disabled) { .primary-button:active:not(:disabled) {
background: #0f5a2b; background: var(--color-brand-hover);
} }
.secondary-button:hover:not(:disabled) { .secondary-button:hover:not(:disabled) {
@@ -335,7 +414,7 @@
gap: 0; gap: 0;
border-radius: 1rem; border-radius: 1rem;
overflow: hidden; overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--color-border);
} }
.empty-shimmer-metrics { .empty-shimmer-metrics {
@@ -343,8 +422,8 @@
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem; gap: 0.75rem;
padding: 1rem; padding: 1rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--color-border);
background: var(--panel-soft); background: var(--color-bg-app);
} }
.shimmer-metric { .shimmer-metric {
@@ -352,9 +431,9 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
padding: 0.85rem; padding: 0.85rem;
border: 1px solid var(--line); border: 1px solid var(--color-border);
border-radius: 0.85rem; border-radius: 0.85rem;
background: var(--panel); background: var(--color-bg-surface);
} }
.empty-state-copy { .empty-state-copy {
@@ -364,14 +443,14 @@
gap: 0.5rem; gap: 0.5rem;
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
text-align: center; text-align: center;
background: var(--panel); background: var(--color-bg-surface);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--color-border);
} }
.empty-state-copy strong { .empty-state-copy strong {
font-size: 0.98rem; font-size: 0.98rem;
font-weight: 700; font-weight: 700;
color: var(--text); color: var(--color-text-primary);
} }
.empty-state-copy span { .empty-state-copy span {
@@ -407,7 +486,7 @@
.empty-shimmer-rows { .empty-shimmer-rows {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--panel-soft); background: var(--color-bg-app);
} }
.shimmer-row { .shimmer-row {
@@ -416,7 +495,7 @@
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
padding: 0.78rem 1rem; padding: 0.78rem 1rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--color-border);
} }
.shimmer-row:last-child { .shimmer-row:last-child {
@@ -429,7 +508,7 @@
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
var(--color-border) 25%, var(--color-border) 25%,
color-mix(in srgb, var(--color-border) 40%, white) 50%, color-mix(in srgb, var(--color-border) 45%, var(--color-bg-surface)) 50%,
var(--color-border) 75% var(--color-border) 75%
); );
background-size: 200% 100%; background-size: 200% 100%;
@@ -452,10 +531,6 @@
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.section-header {
flex-direction: column;
}
.summary-grid { .summary-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -479,14 +554,14 @@
} }
tbody tr { tbody tr {
border: 1px solid var(--line); border: 1px solid var(--color-border);
border-radius: 1rem; border-radius: 1rem;
background: var(--panel-soft); background: var(--color-bg-app);
overflow: hidden; overflow: hidden;
} }
tbody td { tbody td {
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--color-border);
} }
tbody td:last-child { tbody td:last-child {
@@ -497,7 +572,7 @@
content: attr(data-label); content: attr(data-label);
display: block; display: block;
margin-bottom: 0.24rem; margin-bottom: 0.24rem;
color: var(--muted); color: var(--color-text-muted);
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@@ -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;
@@ -57,7 +57,6 @@
.secondary-rail-layout-content > :global(*) { .secondary-rail-layout-content > :global(*) {
flex: 1 0 auto; flex: 1 0 auto;
height: 100%;
min-height: 100%; min-height: 100%;
} }
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
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,
@@ -13,6 +15,7 @@
let { let {
brandHref, brandHref,
currentPath, currentPath,
collapsed = false,
entries, entries,
footerItems, footerItems,
appVersion, appVersion,
@@ -23,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;
@@ -68,66 +72,204 @@
return null; return null;
}); });
// Open the active group once each time it changes. Because this only fires on // Open the active group once each time it changes, collapsing any other open
// a *change* of activeGroupId, a user who manually closes the group they're // group so only one family is ever expanded (accordion). Because this only
// standing in won't have it reopened under them. // fires on a *change* of activeGroupId, a user who manually closes the group
// they're standing in won't have it reopened under them.
$effect(() => { $effect(() => {
const id = activeGroupId; const id = activeGroupId;
if (id && lastAutoExpanded !== id) { if (id) {
if (!openGroups[id]) { if (lastAutoExpanded !== id) {
openGroups[id] = true; openGroups = { [id]: true };
persistOpenState(); persistOpenState();
lastAutoExpanded = id;
} }
lastAutoExpanded = id; } else if (lastAutoExpanded !== null) {
// Just landed on a standalone module (Dashboard, Throughput, Reporting)
// from inside a family. Collapse the open family so the rail tidies itself,
// and forget the last auto-expanded group so returning to it — say
// Throughput → Order Management — fires the expand again. The
// `lastAutoExpanded !== null` guard makes this a one-shot per transition, so
// a group the user manually opens while on a standalone page stays open.
openGroups = {};
persistOpenState();
lastAutoExpanded = null;
} }
}); });
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
// the one. So expanding Order Management folds an already-open Operations away.
function toggleGroup(id: string) { function toggleGroup(id: string) {
openGroups[id] = !isOpen(id); openGroups = isOpen(id) ? {} : { [id]: true };
persistOpenState(); persistOpenState();
} }
const moduleCount = $derived.by(() => // Expand a group without ever collapsing it. Used by a linkable group header
entries.reduce((count, entry) => count + (entry.kind === 'item' ? 1 : entry.group.children.length), 0) // (Order Management) so clicking the label reveals its submenu the same way a
); // toggle group does — the route may not change (you're already on its page),
// so the auto-expand effect can't be relied on to open it. The chevron remains
// the way to collapse the family.
function openGroup(id: string) {
if (isOpen(id)) return;
openGroups = { [id]: true };
persistOpenState();
}
// ── Third-level submenus (e.g. Integrations → Xero) ─────────────
// Tracked independently of the top-level accordion: a nested submenu can be
// open at the same time as its parent group, and toggling a top-level family
// must not wipe a sibling's nested state. Keyed by "<groupId>:<childHref>".
const SUB_STORAGE_KEY = 'hsf:nav:open-subgroups';
function restoreSubState(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
try {
return JSON.parse(window.sessionStorage.getItem(SUB_STORAGE_KEY) ?? '{}');
} catch {
return {};
}
}
let openSubGroups = $state<Record<string, boolean>>(restoreSubState());
let lastAutoExpandedSub = $state<string | null>(null);
let logoutConfirmOpen = $state(false);
function persistSubState() {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(SUB_STORAGE_KEY, JSON.stringify(openSubGroups));
} catch {
// Private-mode storage failures shouldn't break navigation.
}
}
const subKey = (groupId: string, child: NavItem) => `${groupId}:${child.href}`;
/** True when a child row owns a nested submenu whose grandchild is active. */
function subGroupActive(child: NavItem) {
return child.children?.some((g) => matchesRoute(g.href, currentPath, g.exact)) ?? false;
}
const activeSubKey = $derived.by(() => {
for (const entry of entries) {
if (entry.kind !== 'group') continue;
for (const child of entry.group.children) {
if (child.children?.length && subGroupActive(child)) {
return subKey(entry.group.id, child);
}
}
}
return null;
});
// Auto-open the submenu holding the current page, once per change. `untrack`
// reads the current map without making it a dependency — otherwise writing it
// back would retrigger this effect endlessly.
$effect(() => {
const key = activeSubKey;
if (key) {
if (lastAutoExpandedSub !== key) {
openSubGroups = { ...untrack(() => openSubGroups), [key]: true };
persistSubState();
lastAutoExpandedSub = key;
}
} else {
lastAutoExpandedSub = null;
}
});
const isSubOpen = (key: string) => openSubGroups[key] ?? false;
function toggleSubGroup(key: string) {
openSubGroups = { ...openSubGroups, [key]: !isSubOpen(key) };
persistSubState();
}
// Reveal a nested submenu without collapsing it, mirroring openGroup for the
// third level: clicking the Integrations label opens its connected-systems
// list even when the route doesn't change.
function openSubGroup(key: string) {
if (isSubOpen(key)) return;
openSubGroups = { ...openSubGroups, [key]: true };
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)} 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>
<span class="module-pill">{moduleCount} modules</span>
</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}
<span class="rail-section-count">{moduleCount}</span> <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">
@@ -139,32 +281,111 @@
{@const GroupIcon = group.icon} {@const GroupIcon = group.icon}
{@const groupActive = groupHasActiveChild(group, currentPath)} {@const groupActive = groupHasActiveChild(group, currentPath)}
{@const open = isOpen(group.id)} {@const open = isOpen(group.id)}
<div class="rail-group"> {@const headerHref = group.href ?? group.children[0]?.href}
<button {#if collapsed}
type="button" <a
class="rail-row rail-group-toggle" class="rail-row icon-only"
class:within-active={groupActive && !open} class:active={groupActive}
aria-expanded={open} href={headerHref}
onclick={() => toggleGroup(group.id)} use:tooltip={{ label: group.label, placement: 'right' }}
> >
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span> <span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{group.label}</span> </a>
<span class="rail-group-meta"> {:else}
<span class="rail-group-count">{group.children.length}</span> <div class="rail-group">
<span class="rail-chevron" class:open aria-hidden="true"> {#if headerHref}
<ChevronDown size={15} strokeWidth={2} /> <!-- Every family header both navigates and reveals its submenu:
the label goes to the family's landing route (Order Management
→ its queue; Operations → its first tool) and opens the child
list, while the chevron toggles independently. The header never
takes the full active pill — that belongs to the matching child
row — it only gets the subtle within-active emphasis when
collapsed. -->
<div class="rail-group-head" class:within-active={groupActive && !open}>
<a
class="rail-row rail-group-link"
href={headerHref}
onclick={() => openGroup(group.id)}
>
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{group.label}</span>
</a>
<button
type="button"
class="rail-chevron-btn"
aria-expanded={open}
aria-label={`${open ? 'Collapse' : 'Expand'} ${group.label}`}
onclick={() => toggleGroup(group.id)}
>
<span class="rail-chevron" class:open aria-hidden="true">
<ChevronDown size={15} strokeWidth={2} />
</span>
</button>
</div>
{:else}
<button
type="button"
class="rail-row rail-group-toggle"
class:within-active={groupActive && !open}
aria-expanded={open}
onclick={() => toggleGroup(group.id)}
>
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{group.label}</span>
<span class="rail-group-meta">
<span class="rail-chevron" class:open aria-hidden="true">
<ChevronDown size={15} strokeWidth={2} />
</span>
</span> </span>
</span> </button>
</button> {/if}
{#if open} {#if open}
<div class="rail-children"> <div class="rail-children">
{#each group.children as child} {#each group.children as child}
{@render leafLink(child, false)} {#if child.children?.length}
{@const key = subKey(group.id, child)}
{@const subOpen = isSubOpen(key)}
{@const subActive = subGroupActive(child)}
<!-- Third layer: child row links to its own page; chevron
reveals the nested submenu (e.g. Integrations → Xero). -->
<div class="rail-group-head rail-subgroup-head" class:within-active={subActive && !subOpen}>
<a
class="rail-row rail-group-link"
class:active={matchesRoute(child.href, currentPath, child.exact)}
href={child.href}
onclick={() => openSubGroup(key)}
>
<span class="rail-text">{child.label}</span>
{#if child.badge}<span class="rail-badge">{child.badge}</span>{/if}
</a>
<button
type="button"
class="rail-chevron-btn"
aria-expanded={subOpen}
aria-label={`${subOpen ? 'Collapse' : 'Expand'} ${child.label}`}
onclick={() => toggleSubGroup(key)}
>
<span class="rail-chevron" class:open={subOpen} aria-hidden="true">
<ChevronDown size={15} strokeWidth={2} />
</span>
</button>
</div>
{#if subOpen}
<div class="rail-children rail-subchildren">
{#each child.children as grandchild}
{@render leafLink(grandchild, false)}
{/each}
</div>
{/if}
{:else}
{@render leafLink(child, false)}
{/if}
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
{/if}
{/if} {/if}
{/each} {/each}
</nav> </nav>
@@ -179,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>&copy; {currentYear} Hunter Premium Produce</small> <div class="sidebar-meta-bottom">
<div class="powered-by"> <small>&copy; {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
@@ -219,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);
@@ -255,20 +504,6 @@
padding: 0 0.5rem; padding: 0 0.5rem;
} }
.rail-section-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
height: 1.35rem;
padding: 0 0.42rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
color: var(--sidebar-text-muted);
font-size: 0.7rem;
font-weight: 700;
}
.brand-row { .brand-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -287,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;
@@ -309,26 +558,16 @@
line-height: 1.3; line-height: 1.3;
} }
.module-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.34rem 0.58rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-active-bg) 10%, transparent);
color: var(--sidebar-active-bg);
font-size: 0.7rem;
font-weight: 700;
white-space: nowrap;
}
/* ── Navigation rows ─────────────────────────────────────────── */ /* ── Navigation rows ─────────────────────────────────────────── */
.rail-nav { .rail-nav {
display: grid; display: grid;
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;
@@ -407,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);
@@ -425,32 +674,91 @@
gap: 0.42rem; gap: 0.42rem;
} }
.rail-group-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.2rem;
padding: 0 0.32rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
color: var(--sidebar-text-muted);
font-size: 0.66rem;
font-weight: 700;
line-height: 1;
}
.rail-group-toggle.within-active { .rail-group-toggle.within-active {
color: var(--sidebar-text-strong); color: var(--sidebar-text-strong);
font-weight: 600; font-weight: 600;
} }
.rail-group-toggle.within-active .rail-group-count { .rail-group-toggle.within-active .rail-icon,
.rail-group-toggle.within-active .rail-chevron {
color: var(--sidebar-text-strong); color: var(--sidebar-text-strong);
} }
.rail-group-toggle.within-active .rail-icon, /* ── Linkable group header (label links, chevron toggles) ────── */
.rail-group-toggle.within-active .rail-chevron { /* The label and chevron stay separate click targets (navigate vs toggle) but
share one hover pill on the wrapper, so the whole header — icon, title,
chevron — lights up as a single button instead of two halves. */
.rail-group-head {
display: flex;
align-items: center;
gap: 0;
border-radius: 0.8rem;
transition: background-color 160ms ease;
}
.rail-group-head:hover {
background: var(--sidebar-hover);
}
.rail-group-head:hover .rail-group-link,
.rail-group-head:hover .rail-icon,
.rail-group-head:hover .rail-chevron {
color: var(--sidebar-text-strong);
}
/* Let the wrapper own the hover background; the inner targets stay transparent
so they don't paint a second, mismatched pill on top. */
.rail-group-head .rail-group-link:hover,
.rail-group-head .rail-chevron-btn:hover {
background: transparent;
}
/* Category headers carry the lighter second-level row treatment (size, weight,
radius); only the leading icon and chevron mark them as parents. Standalone
top-level destinations (Dashboard, Throughput) keep the larger base row. */
.rail-group-head .rail-group-link,
.rail-group-toggle {
min-height: 2.45rem;
padding: 0.48rem 0.62rem 0.48rem 0.72rem;
font-size: 0.88rem;
border-radius: 0.8rem;
}
.rail-group-head .rail-group-link {
flex: 1;
min-width: 0;
}
.rail-group-head.within-active .rail-group-link {
color: var(--sidebar-text-strong);
font-weight: 600;
}
.rail-group-head.within-active .rail-group-link .rail-icon {
color: var(--sidebar-text-strong);
}
.rail-chevron-btn {
display: inline-flex;
align-items: center;
gap: 0.42rem;
flex-shrink: 0;
min-height: 2.45rem;
padding: 0 0.58rem;
border: none;
border-radius: 0.8rem;
background: transparent;
color: var(--sidebar-icon);
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease;
}
.rail-chevron-btn:hover {
background: var(--sidebar-hover);
color: var(--sidebar-text-strong);
}
.rail-group-head.within-active .rail-chevron-btn {
color: var(--sidebar-text-strong); color: var(--sidebar-text-strong);
} }
@@ -493,6 +801,23 @@
border-radius: 0.8rem; border-radius: 0.8rem;
} }
/* Third level: nest the submenu a little deeper than its parent row, with its
own guide line, so the hierarchy reads as Group Section Item. */
.rail-subgroup-head {
margin-left: 0.1rem;
}
.rail-subchildren {
margin-left: 0.5rem;
padding-left: 0.95rem;
animation: none;
}
.rail-subchildren .rail-row {
min-height: 2.2rem;
font-size: 0.85rem;
}
@keyframes rail-reveal { @keyframes rail-reveal {
from { from {
opacity: 0; opacity: 0;
@@ -522,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;
@@ -531,14 +874,20 @@
font-size: 0.76rem; font-size: 0.76rem;
} }
.sidebar-meta-top, .sidebar-meta-top {
.sidebar-meta-bottom {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.65rem; gap: 0.65rem;
} }
.sidebar-meta-bottom {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.45rem;
}
.sidebar-meta-foot small { .sidebar-meta-foot small {
font-size: 0.72rem; font-size: 0.72rem;
line-height: 1.35; line-height: 1.35;
@@ -590,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,69 +1,102 @@
<script lang="ts"> <script lang="ts">
import { Settings } 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 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
}: { }: {
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;
onShowWhatsNew: () => void;
} = $props(); } = $props();
</script> </script>
<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>
{/if} {/if}
<div class="topbar-actions"> <div class="topbar-actions">
<button
class="whats-new-toggle"
type="button"
onclick={onShowWhatsNew}
aria-label="What's new"
use:tooltip={"What's new — latest updates and changes"}
>
<Sparkles size={18} strokeWidth={1.75} />
</button>
<ThemeToggle /> <ThemeToggle />
<div class="menu-wrap user-menu-wrap"> <div class="menu-wrap user-menu-wrap">
@@ -122,7 +155,7 @@
<style> <style>
.topbar { .topbar {
display: grid; display: grid;
grid-template-columns: 1fr minmax(20rem, 36rem) 1fr; grid-template-columns: minmax(0, 1fr) minmax(20rem, 3fr) minmax(0, 1fr);
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.72rem 1.2rem; padding: 0.72rem 1.2rem;
@@ -141,7 +174,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
padding-right: 0.9rem; /* Extra right padding gives the scaled-up logo room before the divider. */
padding-right: 1.6rem;
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
} }
@@ -149,62 +183,80 @@
height: 2rem; height: 2rem;
width: auto; width: auto;
display: block; display: block;
/* Zoom the wide logo lockup in so the wordmark is legible, without growing
the header: transform scales the visual only, leaving the 2rem layout box
(and therefore the topbar height) untouched. Anchored left so it grows
rightward from the topbar's left padding edge. */
transform: scale(1.45);
transform-origin: left center;
} }
.topbar-copy h1 { .sidebar-toggle {
margin: 0.12rem 0 0; display: inline-flex;
font-size: 1.34rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.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 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.68rem; gap: 0.6rem;
flex-wrap: wrap; /* Never let the What's-new / theme toggles wrap above the user button. */
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. */
.whats-new-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
flex-shrink: 0;
border: 1px solid var(--color-border);
border-radius: 0.82rem;
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.whats-new-toggle:hover {
background: var(--color-surface-hover);
color: var(--color-text-primary);
} }
.workspace-label { .workspace-label {
@@ -390,6 +442,9 @@
background: var(--panel-soft); background: var(--panel-soft);
} }
/* Drop the search to its own row early: with the 252px sidebar present, a
laptop's content width is already tight well above the sidebar's own
collapse point, so keep the top row to brand + actions only. */
@media (max-width: 1180px) { @media (max-width: 1180px) {
.topbar { .topbar {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -404,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 {
@@ -417,13 +483,19 @@
} }
.topbar-brand { .topbar-brand {
padding-right: 0.6rem; padding-right: 1.1rem;
} }
.topbar-brand img { .topbar-brand img {
height: 1.6rem; height: 1.6rem;
} }
/* On phones let the user button drop onto its own line below the icon
toggles again (the desktop nowrap rule would otherwise overflow). */
.topbar-actions {
flex-wrap: wrap;
}
.user-trigger { .user-trigger {
min-width: auto; min-width: auto;
width: 100%; width: 100%;
@@ -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>
@@ -0,0 +1,538 @@
<script lang="ts">
import { Plus, TriangleAlert, X } from 'lucide-svelte';
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
import type { ThroughputProduct, ThroughputQuantityType } from '$lib/types';
let {
products,
editingId,
saving,
nDate = $bindable(''),
nProductId = $bindable(''),
nQuantity = $bindable(''),
nType = $bindable<ThroughputQuantityType>('bags'),
nBagSize = $bindable(''),
nStaff = $bindable(''),
nNotes = $bindable(''),
nForOrder = $bindable(false),
nForStock = $bindable(false),
nJobNumber = $bindable(''),
nStockQty = $bindable(''),
showNote = $bindable(false),
addError,
isSplit,
addTotalKg,
formatNumber,
onSubmit,
onDismissNote,
onCancelEdit,
composerRef = $bindable<HTMLElement | null>(null)
}: {
products: ThroughputProduct[];
editingId: number | null;
saving: boolean;
nDate?: string;
nProductId?: string;
nQuantity?: string;
nType?: ThroughputQuantityType;
nBagSize?: string;
nStaff?: string;
nNotes?: string;
nForOrder?: boolean;
nForStock?: boolean;
nJobNumber?: string;
nStockQty?: string;
showNote?: boolean;
addError: string;
isSplit: boolean;
addTotalKg: number | null;
formatNumber: (value: number | null | undefined, digits?: number) => string;
onSubmit: () => void;
onDismissNote: () => void;
onCancelEdit: () => void;
composerRef?: HTMLElement | null;
} = $props();
</script>
<div class="composer" class:editing={editingId != null} bind:this={composerRef}>
<div class="composer-head">
<div class="composer-title">
<h2>{editingId != null ? 'Edit packing run' : 'Add a packing run'}</h2>
</div>
{#if editingId != null}
<button type="button" class="cancel-edit" onclick={onCancelEdit}>
<X size={16} strokeWidth={2.4} /> Cancel edit
</button>
{/if}
</div>
<form class="add-row" onsubmit={(e) => { e.preventDefault(); onSubmit(); }}>
<div class="add-cell">
<span class="cell-label">Date</span>
<input type="date" bind:value={nDate} aria-label="Production date" />
</div>
<div class="add-cell add-product">
<span class="cell-label">Product</span>
<ThroughputProductPicker {products} bind:productId={nProductId} inputId="throughput-add-product" />
</div>
<div class="add-cell">
<span class="cell-label">Packed</span>
<div class="packed-inputs">
<input
class="qty"
type="number"
min="0"
step="0.01"
inputmode="decimal"
bind:value={nQuantity}
placeholder={nType === 'bags' ? 'Bags' : 'Total kg'}
aria-label={nType === 'bags' ? 'Number of bags' : 'Total kilograms'}
/>
<select class="unit" bind:value={nType} aria-label="Bags or kilograms">
<option value="bags">bags</option>
<option value="kg">kg (bulka)</option>
</select>
{#if nType === 'bags'}
<span class="times" aria-hidden="true">×</span>
<input
class="bag"
type="number"
min="0"
step="0.01"
inputmode="decimal"
bind:value={nBagSize}
placeholder="kg/bag"
aria-label="Kilograms per bag"
/>
{/if}
</div>
{#if nType === 'bags' && addTotalKg !== null}
<span class="packed-total">= {formatNumber(addTotalKg)} kg total</span>
{/if}
</div>
<div class="add-cell">
<span class="cell-label">Packed by</span>
<input type="text" bind:value={nStaff} placeholder="Name" aria-label="Packed by" />
</div>
<div class="add-cell add-dest">
<span class="cell-label">Destination</span>
<div class="dest-rows">
<div class="dest-line">
<label class="dest-toggle" class:on={nForOrder}>
<input type="checkbox" bind:checked={nForOrder} /> For an order
</label>
{#if nForOrder}
<input
class="dest-input"
type="text"
bind:value={nJobNumber}
placeholder="Job number (Order Circle)"
aria-label="Job number"
/>
{/if}
</div>
<div class="dest-line">
<label class="dest-toggle" class:on={nForStock}>
<input type="checkbox" bind:checked={nForStock} /> For stock
</label>
{#if isSplit}
<input
class="dest-input"
type="number"
min="0"
step="0.01"
inputmode="decimal"
bind:value={nStockQty}
placeholder={`To stock (${nType === 'bags' ? 'bags' : 'kg'})`}
aria-label="Amount going to stock"
/>
{/if}
</div>
</div>
</div>
<div class="add-cell add-action">
<button type="submit" class="add-entry-button" disabled={saving}>
<Plus size={18} strokeWidth={2.6} />
<span>{saving ? 'Saving…' : editingId != null ? 'Save' : 'Add'}</span>
</button>
</div>
<div class="add-extra">
{#if showNote}
<div class="note-field">
<input
class="note-input"
type="text"
bind:value={nNotes}
placeholder="Note (optional)"
aria-label="Note"
/>
<button
type="button"
class="note-dismiss"
title="Remove note"
aria-label="Remove note"
onclick={onDismissNote}
>
<X size={16} strokeWidth={2.4} />
</button>
</div>
{:else}
<button type="button" class="link-button" onclick={() => (showNote = true)}>+ Add a note</button>
{/if}
{#if addError}
<span class="add-error"><TriangleAlert size={15} strokeWidth={2.4} /> {addError}</span>
{/if}
</div>
</form>
</div>
<style>
.composer {
display: grid;
gap: 0.85rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
border-radius: 1rem;
background: var(--color-bg-surface);
box-shadow: 0 16px 34px -28px rgba(15, 23, 42, 0.28);
/* The product picker menu is absolutely positioned and needs to escape the
card bounds without being cut off. */
overflow: visible;
}
.composer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.45rem 0;
}
.composer-title h2 {
margin: 0;
font-size: 1.22rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.add-row {
display: grid;
grid-template-columns: 7.5rem minmax(19rem, 1.7fr) minmax(14rem, 1.15fr) minmax(7rem, 0.65fr) minmax(14rem, 1.2fr) auto;
gap: 0.75rem 0.85rem;
align-items: start;
padding: 0 1.45rem 1.25rem;
}
.add-cell {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.add-row input:not([type='checkbox']),
.add-row select {
width: 100%;
min-height: 48px;
padding: 0.62rem 0.78rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.8rem;
font-size: 0.98rem;
color: var(--color-text-primary);
background: var(--color-bg-surface);
}
.add-row input:not([type='checkbox']):focus-visible,
.add-row select:focus-visible,
.cancel-edit:focus-visible,
.add-entry-button:focus-visible,
.note-dismiss:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
.packed-inputs {
display: flex;
align-items: center;
gap: 0.4rem;
}
.packed-inputs .qty {
flex: 1 1 4.5rem;
min-width: 0;
}
.packed-inputs .unit {
flex: 0 0 4.7rem;
width: 4.7rem;
}
.packed-inputs .bag {
flex: 0 0 6rem;
width: 6rem;
}
.packed-inputs .times {
flex: 0 0 auto;
color: var(--color-text-secondary);
font-weight: 700;
}
.packed-total {
margin-top: 0.3rem;
font-size: 0.84rem;
font-weight: 650;
color: var(--color-success);
font-variant-numeric: tabular-nums;
}
.add-dest {
gap: 0.4rem;
}
.dest-rows {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dest-line {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dest-line .dest-toggle {
flex: 0 0 auto;
min-width: 8.5rem;
}
.dest-line .dest-input {
flex: 1 1 auto;
min-width: 0;
width: auto;
}
.dest-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.68rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.72rem;
background: var(--color-bg-surface);
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.dest-toggle input {
width: 1.2rem;
height: 1.2rem;
min-height: 0;
margin: 0;
flex-shrink: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.dest-toggle.on {
border-color: var(--color-brand);
background: var(--color-brand-tint);
color: var(--color-success);
}
.dest-input {
width: 100%;
}
.add-action {
justify-content: center;
}
.add-entry-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 48px;
padding: 0.62rem 1.28rem;
background: var(--color-brand);
color: #fff;
border: 1px solid var(--color-brand);
border-radius: 0.8rem;
font-size: 0.98rem;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
transition: background-color 160ms ease;
}
.add-entry-button:hover:not(:disabled) {
background: #126a33;
}
.add-entry-button:disabled {
opacity: 0.65;
cursor: progress;
}
.add-extra {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
padding-top: 0.15rem;
}
.link-button {
padding: 0.25rem 0;
background: none;
border: 0;
color: var(--color-success);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
.link-button:hover {
text-decoration: underline;
}
.note-field {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1 1 16rem;
max-width: 28rem;
}
.note-input {
flex: 1 1 auto;
min-width: 0;
}
.note-dismiss,
.cancel-edit {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
}
.note-dismiss {
flex-shrink: 0;
width: 2.1rem;
height: 2.1rem;
padding: 0;
border-radius: 0.55rem;
transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease;
}
.cancel-edit {
gap: 0.35rem;
padding: 0.5rem 0.85rem;
border-radius: 0.6rem;
font-size: 0.92rem;
font-weight: 600;
}
.note-dismiss:hover,
.cancel-edit:hover {
color: var(--color-text-primary);
border-color: var(--color-text-muted);
background: #fafbfc;
}
.add-error {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: #8a1622;
font-weight: 650;
font-size: 0.95rem;
}
.cell-label {
display: none;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-muted);
}
.composer.editing {
border-color: var(--color-brand);
box-shadow: 0 0 0 1px var(--color-brand) inset;
}
@media (max-width: 1440px) {
.add-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem 1rem;
}
.add-action {
justify-content: flex-end;
}
.add-entry-button {
width: 100%;
}
}
@media (max-width: 1040px) {
.add-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem 1rem;
}
.add-cell:nth-child(2),
.add-cell:nth-child(3),
.add-dest,
.add-action {
grid-column: 1 / -1;
}
.add-entry-button {
width: 100%;
}
}
@media (max-width: 760px) {
.composer-head {
padding-left: 1.15rem;
padding-right: 1.15rem;
}
.composer-title h2 {
font-size: 1.28rem;
}
.add-row {
grid-template-columns: 1fr 1fr;
gap: 0.85rem 1rem;
padding: 0 1.15rem 1.15rem;
}
.add-cell .cell-label {
display: block;
}
.add-cell:nth-child(2),
.add-dest,
.add-action {
grid-column: 1 / -1;
}
.add-entry-button {
width: 100%;
}
}
</style>
@@ -0,0 +1,172 @@
<script lang="ts">
import { Trash2 } from 'lucide-svelte';
import type { ThroughputEntry } from '$lib/types';
let {
pendingDelete,
deletingId,
formatDate,
onCancel,
onConfirm,
dialogRef = $bindable<HTMLElement | null>(null)
}: {
pendingDelete: ThroughputEntry;
deletingId: number | null;
formatDate: (value: string) => string;
onCancel: () => void;
onConfirm: () => void;
dialogRef?: HTMLElement | null;
} = $props();
</script>
<div class="modal-backdrop" role="presentation" onclick={onCancel}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="delete-title"
tabindex="-1"
bind:this={dialogRef}
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') onCancel(); }}
>
<div class="modal-icon"><Trash2 size={22} strokeWidth={2.2} /></div>
<h2 id="delete-title" class="modal-title">Delete this run?</h2>
<p class="modal-text">
The <strong>{pendingDelete.product_name_snapshot}</strong> run from
{formatDate(pendingDelete.production_date)} will be permanently removed.
This cannot be undone.
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={onCancel}>Cancel</button>
<button
type="button"
class="modal-confirm"
disabled={deletingId === pendingDelete.id}
onclick={onConfirm}
>
{deletingId === pendingDelete.id ? 'Deleting…' : 'Delete run'}
</button>
</div>
</div>
</div>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(28rem, 100%);
display: grid;
gap: 0.7rem;
padding: 1.6rem;
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-bg-surface);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
animation: modal-pop 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.modal-card:focus {
outline: none;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
border-radius: 0.8rem;
background: #fdecee;
color: #b3261e;
}
.modal-title,
.modal-text {
margin: 0;
}
.modal-title {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text-primary);
}
.modal-text {
font-size: 0.98rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.55rem;
}
.modal-cancel,
.modal-confirm {
min-height: 44px;
padding: 0.6rem 1.15rem;
border-radius: 0.7rem;
font-size: 0.98rem;
font-weight: 650;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease;
}
.modal-cancel {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.modal-cancel:hover {
color: var(--color-text-primary);
border-color: var(--color-text-muted);
}
.modal-confirm {
background: #b3261e;
border: 1px solid #b3261e;
color: #fff;
}
.modal-confirm:hover:not(:disabled) {
background: #95201a;
}
.modal-confirm:disabled {
opacity: 0.65;
cursor: progress;
}
.modal-cancel:focus-visible,
.modal-confirm:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
@keyframes modal-pop {
from { opacity: 0; transform: translateY(6px) scale(0.985); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.modal-card {
animation: none;
}
}
</style>
@@ -0,0 +1,771 @@
<script lang="ts">
import { ArrowUpDown, ChevronLeft, ChevronRight, History, Pencil, Search, Trash2, TriangleAlert, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import type { SortKey } from '$lib/components/throughput/utils';
import type { ThroughputEntry, ThroughputProduct, ThroughputQuantityType } from '$lib/types';
let {
products,
filtersActive,
showFilters = $bindable(false),
dateFrom = $bindable(''),
dateTo = $bindable(''),
productFilter = $bindable(''),
staffFilter = $bindable(''),
typeFilter = $bindable<'' | ThroughputQuantityType>(''),
isLoading,
errorMessage,
sortKey,
highlightId,
deletingId,
sortedEntries,
paginatedEntries,
page = $bindable(1),
totalPages,
pageStart,
pageEnd,
formatDate,
formatNumber,
packedMain,
packedDetail,
destinationOf,
onApplyFilters,
onClearFilters,
onToggleSort,
onStartEdit,
onRequestDelete
}: {
products: ThroughputProduct[];
filtersActive: boolean;
showFilters?: boolean;
dateFrom?: string;
dateTo?: string;
productFilter?: string;
staffFilter?: string;
typeFilter?: '' | ThroughputQuantityType;
isLoading: boolean;
errorMessage: string;
sortKey: SortKey;
highlightId: number | null;
deletingId: number | null;
sortedEntries: ThroughputEntry[];
paginatedEntries: ThroughputEntry[];
page: number;
totalPages: number;
pageStart: number;
pageEnd: number;
formatDate: (value: string) => string;
formatNumber: (value: number | null | undefined, digits?: number) => string;
packedMain: (entry: ThroughputEntry) => string;
packedDetail: (entry: ThroughputEntry) => string;
destinationOf: (entry: ThroughputEntry) => { label: string; detail: string | null };
onApplyFilters: () => void;
onClearFilters: () => void;
onToggleSort: (key: SortKey) => void;
onStartEdit: (entry: ThroughputEntry) => void;
onRequestDelete: (entry: ThroughputEntry) => void;
} = $props();
</script>
<div class="history-shell">
<div class="log-controls">
<div class="log-title">
<h2 class="history-title">
{#if !filtersActive}<History size={18} strokeWidth={2.1} />{/if}
<span>{filtersActive ? 'Filtered entries' : 'Recent entries'}</span>
</h2>
<span class="log-subtitle">{filtersActive ? 'Matching runs' : 'Last 30 days'}</span>
</div>
<button
type="button"
class="find-button"
class:active={showFilters}
aria-expanded={showFilters}
onclick={() => (showFilters = !showFilters)}
>
<Search size={18} strokeWidth={2.2} />
<span>Find past entries</span>
{#if filtersActive}<span class="find-dot" aria-label="filters applied"></span>{/if}
</button>
</div>
{#if showFilters}
<form
class="filters"
transition:fade={{ duration: 120 }}
onsubmit={(e) => {
e.preventDefault();
onApplyFilters();
}}
>
<label>
<span>From date</span>
<input type="date" bind:value={dateFrom} />
</label>
<label>
<span>To date</span>
<input type="date" bind:value={dateTo} />
</label>
<label>
<span>Product</span>
<select bind:value={productFilter}>
<option value="">All products</option>
{#each products as product (product.id)}
<option value={String(product.id)}>{product.name}</option>
{/each}
</select>
</label>
<label>
<span>Staff</span>
<input type="text" placeholder="Name" bind:value={staffFilter} />
</label>
<label>
<span>Packed as</span>
<select bind:value={typeFilter}>
<option value="">Bags or kg</option>
<option value="bags">Bags</option>
<option value="kg">Kilograms</option>
</select>
</label>
<div class="filter-actions">
<button type="submit" class="apply-button" disabled={isLoading}>
{isLoading ? 'Searching…' : 'Search'}
</button>
{#if filtersActive}
<button type="button" class="clear-button" onclick={onClearFilters}>
<X size={16} strokeWidth={2.4} /> Clear
</button>
{/if}
</div>
</form>
{/if}
{#if errorMessage}
<div class="error" role="alert">
<TriangleAlert size={20} strokeWidth={2.2} />
<span>{errorMessage}</span>
<button type="button" class="retry-button" onclick={onApplyFilters}>Try again</button>
</div>
{/if}
<div class="log">
<div class="log-head">
<button type="button" class="sort-head" class:active={sortKey === 'date'} onclick={() => onToggleSort('date')}>
<span>Date</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'product'} onclick={() => onToggleSort('product')}>
<span>Product</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'packed'} onclick={() => onToggleSort('packed')}>
<span>Packed</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'total'} onclick={() => onToggleSort('total')}>
<span>Total kg</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'staff'} onclick={() => onToggleSort('staff')}>
<span>Packed by</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'destination'} onclick={() => onToggleSort('destination')}>
<span>Destination</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head col-notes-head" class:active={sortKey === 'notes'} onclick={() => onToggleSort('notes')}>
<span>Notes</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<span class="col-actions-head">Edit</span>
</div>
{#if isLoading}
{#each Array(5) as _, i (i)}
<div class="row row-skeleton" aria-hidden="true">
<span class="sk sk-date"></span>
<span class="sk sk-product"></span>
<span class="sk sk-packed"></span>
<span class="sk sk-staff"></span>
<span class="sk sk-qa"></span>
</div>
{/each}
{:else}
{#each paginatedEntries as entry (entry.id)}
{@const dest = destinationOf(entry)}
<div class="row" class:just-added={entry.id === highlightId}>
<span class="col-date">
<span class="cell-label">Date</span>
{formatDate(entry.production_date)}
</span>
<span class="col-product">
<span class="cell-label">Product</span>
<span class="product-name">{entry.product_name_snapshot}</span>
</span>
<span class="col-packed">
<span class="cell-label">Packed</span>
<span class="packed-main">{packedMain(entry)}</span>
<span class="packed-detail">{packedDetail(entry)}</span>
</span>
<span class="col-total">
<span class="cell-label">Total kg</span>
<span class="total-kg">{formatNumber(entry.calculated_kg)} kg</span>
</span>
<span class="col-staff">
<span class="cell-label">Packed by</span>
{entry.staff_name ?? '—'}
</span>
<span class="col-dest">
<span class="cell-label">Destination</span>
<span
class="pill"
class:pill-stock={dest.label === 'Stock'}
class:pill-order={dest.label === 'Order'}
class:pill-split={dest.label === 'Split'}
>{dest.label}</span>
{#if dest.detail}<span class="dest-detail">{dest.detail}</span>{/if}
</span>
<span class="col-actions">
<button
type="button"
class="row-action"
title="Edit this run"
aria-label="Edit this run"
onclick={() => onStartEdit(entry)}
>
<Pencil size={16} strokeWidth={2.2} />
</button>
<button
type="button"
class="row-action row-action-danger"
title="Delete this run"
aria-label="Delete this run"
disabled={deletingId === entry.id}
onclick={() => onRequestDelete(entry)}
>
<Trash2 size={16} strokeWidth={2.2} />
</button>
</span>
{#if entry.notes}
<p class="row-notes"><span class="cell-label">Note</span>{entry.notes}</p>
{/if}
</div>
{:else}
<div class="empty">
{#if filtersActive}
<p class="empty-title">No entries match your search</p>
<p class="empty-help">Try a wider date range, or clear the filters to see everything.</p>
<button type="button" class="clear-button" onclick={onClearFilters}>
<X size={16} strokeWidth={2.4} /> Clear filters
</button>
{:else}
<p class="empty-title">No packing logged yet</p>
<p class="empty-help">Use the inline entry area above to add your first run. It appears here immediately, newest first.</p>
{/if}
</div>
{/each}
{/if}
</div>
{#if sortedEntries.length > 0}
<div class="pagination">
<p class="pagination-summary">{pageStart}-{pageEnd} of {sortedEntries.length}</p>
<div class="pagination-actions">
<button type="button" class="page-button" disabled={page === 1} onclick={() => (page = Math.max(1, page - 1))}>
<ChevronLeft size={16} strokeWidth={2.2} />
<span>Previous</span>
</button>
<span class="page-indicator">Page {page} of {totalPages}</span>
<button type="button" class="page-button" disabled={page === totalPages} onclick={() => (page = Math.min(totalPages, page + 1))}>
<span>Next</span>
<ChevronRight size={16} strokeWidth={2.2} />
</button>
</div>
</div>
{/if}
</div>
<style>
.history-shell {
display: grid;
gap: 0.95rem;
}
.log-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.log-title {
display: grid;
gap: 0.2rem;
}
.history-title {
display: inline-flex;
align-items: center;
gap: 0.55rem;
margin: 0;
font-size: 1.16rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.log-subtitle {
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.find-button,
.apply-button,
.clear-button,
.retry-button,
.sort-head,
.page-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
cursor: pointer;
}
.find-button {
position: relative;
min-height: 44px;
padding: 0.6rem 0.95rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-border));
border-radius: 0.78rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: 0.95rem;
font-weight: 650;
}
.find-button.active {
border-color: var(--color-brand);
background: var(--color-brand-tint);
color: var(--color-success);
}
.find-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-brand);
}
.filters {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)) auto;
gap: 0.8rem;
align-items: end;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 0.95rem;
background: color-mix(in srgb, var(--color-bg-surface) 82%, white);
}
.filters label {
display: grid;
gap: 0.32rem;
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.filters input,
.filters select {
min-height: 44px;
padding: 0.58rem 0.72rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 12%, var(--color-border));
border-radius: 0.78rem;
font-size: 0.97rem;
color: var(--color-text-primary);
background: var(--color-bg-surface);
}
.filter-actions {
display: inline-flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.apply-button,
.clear-button,
.retry-button,
.page-button {
min-height: 42px;
padding: 0.55rem 0.82rem;
border: 1px solid var(--color-border);
border-radius: 0.72rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: 0.92rem;
font-weight: 650;
}
.apply-button {
border-color: var(--color-brand);
background: var(--color-brand);
color: #fff;
}
.clear-button {
color: var(--color-text-secondary);
}
.error {
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
padding: 0.95rem 1rem;
border: 1px solid #efc6c2;
border-radius: 0.9rem;
background: #fff4f2;
color: #8a1622;
}
.log {
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-bg-surface);
overflow: clip;
}
.log-head,
.row {
display: grid;
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 10.5rem 4.8rem;
gap: 0.85rem;
align-items: center;
}
.log-head {
padding: 0.9rem 1.45rem;
border-bottom: 1px solid var(--color-divider);
background: color-mix(in srgb, var(--color-bg-app) 55%, var(--color-bg-surface));
}
.sort-head {
justify-content: flex-start;
padding: 0;
border: 0;
background: none;
color: var(--color-text-secondary);
font-size: 0.8rem;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sort-head.active,
.sort-head:hover {
color: var(--color-text-primary);
}
.row {
padding: 1rem 1.45rem;
border-bottom: 1px solid var(--color-divider);
transition: background-color 160ms ease;
}
.row:last-child {
border-bottom: 0;
}
.row:hover {
background: #fafbfc;
}
.row.just-added {
animation: flash-in 1.8s ease-out;
}
@keyframes flash-in {
0% { background: var(--color-brand-tint); }
100% { background: transparent; }
}
.col-product,
.col-dest,
.col-packed,
.col-total {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
.product-name,
.packed-main,
.total-kg {
font-weight: 650;
}
.dest-detail,
.packed-detail {
font-size: 0.88rem;
color: var(--color-text-secondary);
}
.total-kg,
.packed-main,
.dest-detail {
font-variant-numeric: tabular-nums;
}
.col-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.3rem;
}
.col-actions-head {
justify-self: end;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.row-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 0.55rem;
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease;
}
.row-action:hover {
border-color: var(--color-text-muted);
color: var(--color-text-primary);
background: #fafbfc;
}
.row-action-danger:hover {
border-color: #e2a8af;
color: #b3261e;
background: #fdecee;
}
.row-action:disabled,
.page-button:disabled,
.apply-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.row-notes {
grid-column: 1 / -1;
margin: 0.35rem 0 0;
padding-top: 0.65rem;
border-top: 1px dashed var(--color-divider);
font-size: 0.94rem;
color: var(--color-text-secondary);
}
.cell-label {
display: none;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-muted);
}
.col-notes-head {
display: none;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 650;
white-space: nowrap;
}
.pill-stock {
background: #e8f1fc;
color: #0b5cad;
}
.pill-order {
background: var(--color-brand-tint);
color: var(--color-success);
}
.pill-split {
background: #f3e8fc;
color: #6b21a8;
}
.row-skeleton {
padding: 1.15rem 1.45rem;
border-bottom: 1px solid var(--color-divider);
}
.sk {
height: 1.1rem;
border-radius: 0.4rem;
background: linear-gradient(90deg, #eef1f4 25%, #f6f8fa 50%, #eef1f4 75%);
background-size: 200% 100%;
animation: shimmer 1.3s ease-in-out infinite;
}
.sk-staff { width: 70%; }
.sk-qa { width: 6rem; height: 1.7rem; border-radius: 999px; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
padding: 3rem 1.5rem;
text-align: center;
}
.empty-title {
margin: 0;
font-size: 1.3rem;
font-weight: 650;
color: var(--color-text-primary);
}
.empty-help {
margin: 0;
font-size: 1.02rem;
color: var(--color-text-secondary);
max-width: 42ch;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 1rem 1.45rem 1.15rem;
border-top: 1px solid var(--color-divider);
background: color-mix(in srgb, var(--color-bg-app) 48%, var(--color-bg-surface));
}
.pagination-summary,
.page-indicator {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.94rem;
font-weight: 600;
}
.pagination-actions {
display: inline-flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
@media (max-width: 760px) {
.log-controls,
.filters,
.error {
padding-left: 1.15rem;
padding-right: 1.15rem;
}
.history-title {
font-size: 1.28rem;
}
.log-head {
display: none;
}
.row {
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1rem;
padding: 1rem 1.15rem;
}
.col-product,
.row-notes {
grid-column: 1 / -1;
}
.cell-label {
display: block;
margin-bottom: 0.1rem;
}
.col-actions {
grid-column: 1 / -1;
justify-content: flex-start;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--color-divider);
}
.row-action {
width: 2.5rem;
height: 2.5rem;
}
.pagination {
padding-left: 1.15rem;
padding-right: 1.15rem;
}
}
@media (min-width: 1280px) {
.log-head,
.row {
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 10.5rem minmax(0, 1.2fr) 4.8rem;
}
.col-notes-head {
display: block;
}
.row-notes {
grid-column: 7;
align-self: center;
margin: 0;
padding-top: 0;
border-top: 0;
font-size: 0.95rem;
}
.col-actions {
grid-column: 8;
}
}
@media (prefers-reduced-motion: reduce) {
.row.just-added,
.sk {
animation: none;
}
}
</style>
@@ -61,6 +61,15 @@
} }
}); });
// Reflect a selection set from outside (e.g. when an entry is loaded into the
// composer to be edited) so the search box shows the chosen product, not blank.
$effect(() => {
if (productId && !focused) {
const match = products.find((p) => String(p.id) === productId);
if (match) query = label(match);
}
});
// If the active client no longer contains the selected product, drop it. // If the active client no longer contains the selected product, drop it.
$effect(() => { $effect(() => {
if (selected && clientName && (selected.client_name ?? '') !== clientName) { if (selected && clientName && (selected.client_name ?? '') !== clientName) {
@@ -120,20 +129,18 @@
</script> </script>
<div class="picker" bind:this={root} onfocusin={() => (focused = true)} onfocusout={onFocusOut}> <div class="picker" bind:this={root} onfocusin={() => (focused = true)} onfocusout={onFocusOut}>
<div class="client-row"> <select
<label class="client-label" for={`${inputId}-client`}>Client</label> id={`${inputId}-client`}
<select class="client-select"
id={`${inputId}-client`} bind:value={clientName}
class="client-select" aria-label="Filter by client"
bind:value={clientName} {disabled}
{disabled} >
> <option value="">All clients</option>
<option value="">All clients</option> {#each clients as client (client)}
{#each clients as client (client)} <option value={client}>{client}</option>
<option value={client}>{client}</option> {/each}
{/each} </select>
</select>
</div>
<div class="combo" role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-controls={`${inputId}-list`}> <div class="combo" role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-controls={`${inputId}-list`}>
<span class="combo-icon" aria-hidden="true"><Search size={16} strokeWidth={2.2} /></span> <span class="combo-icon" aria-hidden="true"><Search size={16} strokeWidth={2.2} /></span>
@@ -196,30 +203,23 @@
</div> </div>
<style> <style>
/* Client filter and product search sit side by side so the product is never
stacked underneath the client. They wrap to two rows only when the cell is
too narrow to keep both readable. */
.picker { .picker {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 0.4rem; align-items: stretch;
gap: 0.45rem;
min-width: 0; min-width: 0;
} }
.client-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.client-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
}
.client-select { .client-select {
flex: 1 1 auto; flex: 0 1 8.5rem;
min-width: 0; min-width: 6rem;
min-height: 40px; min-height: 48px;
padding: 0.4rem 0.55rem; padding: 0.4rem 0.55rem;
border: 1px solid var(--color-border, #d1d5db); border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.5rem; border-radius: 0.55rem;
font: inherit; font: inherit;
background: var(--color-bg-surface, #fff); background: var(--color-bg-surface, #fff);
color: var(--color-text-primary, #111827); color: var(--color-text-primary, #111827);
@@ -228,8 +228,17 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
@media (max-width: 560px) {
.picker {
flex-wrap: wrap;
}
.client-select {
flex: 1 1 100%;
}
}
.combo-icon { .combo-icon {
position: absolute; position: absolute;
left: 0.6rem; left: 0.6rem;
@@ -276,7 +285,7 @@
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; left: 0;
right: 0; right: 0;
z-index: 50; z-index: 200;
margin: 0; margin: 0;
padding: 0.25rem; padding: 0.25rem;
list-style: none; list-style: none;
@@ -0,0 +1,120 @@
<script lang="ts">
import { CheckCircle2 } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import type { ConfettiPiece } from '$lib/components/throughput/utils';
let { confetti }: { confetti: ConfettiPiece[] } = $props();
</script>
<div class="success-overlay" role="presentation" transition:fade={{ duration: 160 }}>
<div class="success-card" role="status" aria-live="polite">
<div class="confetti" aria-hidden="true">
{#each confetti as piece, i (i)}
<span
class="confetti-piece"
style="left:{piece.left}%; width:{piece.size}px; height:{piece.size}px; background:{piece.color}; animation-delay:{piece.delay}s; animation-duration:{piece.duration}s; --rot:{piece.rotate}deg; --drift:{piece.drift}px;"
></span>
{/each}
</div>
<span class="success-icon"><CheckCircle2 size={44} strokeWidth={2.4} /></span>
<p class="success-title">Added</p>
<p class="success-text">Your packing run has been added to the log.</p>
</div>
</div>
<style>
.success-overlay {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
pointer-events: none;
overflow: hidden;
}
.success-card {
position: relative;
overflow: hidden;
width: min(30rem, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.7rem;
padding: 2.6rem 2.8rem 2.8rem;
border: 1px solid var(--color-border);
border-radius: 1.25rem;
background: var(--color-bg-surface);
box-shadow: 0 28px 70px -18px rgba(0, 0, 0, 0.45);
text-align: center;
animation: success-pop 240ms cubic-bezier(0.18, 0.89, 0.32, 1.28);
}
.success-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4.2rem;
height: 4.2rem;
border-radius: 50%;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.success-title,
.success-text {
margin: 0;
}
.success-title {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text-primary);
}
.success-text {
font-size: 1rem;
line-height: 1.45;
color: var(--color-text-secondary);
}
.confetti {
position: absolute;
inset: 0;
pointer-events: none;
}
.confetti-piece {
position: absolute;
top: -12%;
display: block;
border-radius: 2px;
opacity: 0;
animation-name: confetti-fall;
animation-timing-function: ease-in;
animation-iteration-count: infinite;
}
@keyframes success-pop {
from { opacity: 0; transform: scale(0.82); }
to { opacity: 1; transform: scale(1); }
}
@keyframes confetti-fall {
0% { transform: translate(0, 0) rotate(0deg); opacity: 0; }
12% { opacity: 1; }
100% { transform: translate(var(--drift), 340px) rotate(var(--rot)); opacity: 0.9; }
}
@media (prefers-reduced-motion: reduce) {
.success-card {
animation: none;
}
.confetti {
display: none;
}
}
</style>
@@ -0,0 +1,256 @@
<script lang="ts">
import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte';
import { MIX_RANGES } from '$lib/components/throughput/utils';
let {
today,
weekRangeLabel,
heroStats,
mixTotals,
mixRangeKey = $bindable<(typeof MIX_RANGES)[number]['key']>('4w'),
formatDate,
formatNumber
}: {
today: string;
weekRangeLabel: string;
heroStats: { today: number; thisWeek: number; avgFourWeek: number };
mixTotals: { horse: number; grain: number };
mixRangeKey?: (typeof MIX_RANGES)[number]['key'];
formatDate: (value: string) => string;
formatNumber: (value: number | null | undefined, digits?: number) => string;
} = $props();
</script>
<header class="throughput-summary" aria-label="Throughput summary">
<div class="summary-heading">
<span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span>
<h2>Throughput Overview</h2>
<div class="range-select" role="group" aria-label="Customer mix date range">
{#each MIX_RANGES as range (range.key)}
<button
type="button"
class="range-option"
class:active={mixRangeKey === range.key}
aria-pressed={mixRangeKey === range.key}
onclick={() => (mixRangeKey = range.key)}
>{range.label}</button>
{/each}
</div>
</div>
<dl class="facts">
<div class="fact">
<dt><span class="fact-icon"><CalendarDays size={16} strokeWidth={2.2} /></span>Today</dt>
<dd>{formatNumber(heroStats.today)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">{formatDate(today)}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><CalendarRange size={16} strokeWidth={2.2} /></span>This week</dt>
<dd>{formatNumber(heroStats.thisWeek)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">{weekRangeLabel}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><TrendingUp size={16} strokeWidth={2.2} /></span>4-week average</dt>
<dd>{formatNumber(heroStats.avgFourWeek)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">Per week, last 4 weeks</p>
</div>
</dl>
<dl class="mix-facts" aria-label="Throughput by customer">
<div class="fact">
<dt><span class="fact-icon"><Carrot size={16} strokeWidth={2.2} /></span>Horse Mix</dt>
<dd>{formatNumber(mixTotals.horse)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">PHF Horsemix · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><Wheat size={16} strokeWidth={2.2} /></span>Grain Mix</dt>
<dd>{formatNumber(mixTotals.grain)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">All other customers · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
</div>
</dl>
</header>
<style>
.throughput-summary {
display: grid;
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-border));
border-radius: 0.95rem;
background:
radial-gradient(circle at top right, color-mix(in srgb, var(--color-brand) 12%, transparent), transparent 45%),
linear-gradient(180deg, color-mix(in srgb, var(--color-brand) 5%, var(--color-bg-surface)), var(--color-bg-surface));
}
.summary-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem 0.75rem;
padding: 1rem 1rem 0;
}
.summary-heading h2 {
margin: 0;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.summary-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 0.65rem;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.range-select {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: auto;
padding: 0.28rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.8rem;
background: color-mix(in srgb, var(--color-bg-surface) 55%, transparent);
}
.range-option {
padding: 0.5rem 0.95rem;
border: 0;
border-radius: 0.6rem;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.95rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.range-option:hover {
color: var(--color-text-primary);
}
.range-option.active {
background: var(--color-brand);
color: #fff;
box-shadow: 0 8px 18px -14px color-mix(in srgb, var(--color-brand) 85%, transparent);
}
.facts,
.mix-facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
padding: 1rem;
}
.mix-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding-top: 0;
}
.fact {
container-type: inline-size;
position: relative;
display: grid;
gap: 0.28rem;
min-height: 7rem;
padding: 1rem 1rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 12%, var(--color-border));
border-radius: 0.95rem;
background: color-mix(in srgb, var(--color-bg-surface) 84%, white);
box-shadow: 0 12px 36px -28px rgba(15, 23, 42, 0.28);
}
.fact dt {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin: 0;
font-size: 0.83rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.fact-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.7rem;
height: 1.7rem;
border-radius: 0.6rem;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.fact dd {
margin: 0;
font-size: clamp(1.85rem, 4.5cqi, 2.3rem);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.03em;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
.fact-unit {
font-size: 0.45em;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.fact-sub {
margin: 0.05rem 0 0;
color: var(--color-text-secondary);
font-size: 0.88rem;
line-height: 1.35;
}
@media (min-width: 1024px) {
.throughput-summary {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.75rem;
padding: 1rem;
}
.facts,
.mix-facts {
display: contents;
}
.summary-heading {
grid-column: 1 / -1;
padding: 0;
}
.fact {
min-height: 7rem;
padding: 0.9rem 0.85rem;
}
.fact-sub {
font-size: 0.78rem;
}
}
@media (max-width: 760px) {
.facts {
grid-template-columns: 1fr;
padding: 0.9rem;
}
.mix-facts {
grid-template-columns: 1fr;
padding: 0 0.9rem 0.9rem;
}
}
</style>
@@ -0,0 +1,77 @@
import type { ThroughputEntry } from '$lib/types';
export type SortKey = 'date' | 'product' | 'packed' | 'total' | 'staff' | 'destination' | 'notes';
export type SortDirection = 'asc' | 'desc';
export type ConfettiPiece = {
left: number;
delay: number;
duration: number;
color: string;
rotate: number;
drift: number;
size: number;
};
export const CONFETTI_COLORS = ['#16a34a', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444'];
export const MIX_RANGES = [
{ key: '7d', label: '7 days', sub: 'last 7 days', days: 7 },
{ key: '4w', label: '4 weeks', sub: 'last 4 weeks', days: 28 },
{ key: '6w', label: '6 weeks', sub: 'last 6 weeks', days: 42 },
{ key: '12w', label: '12 weeks', sub: 'last 12 weeks', days: 84 }
] as const;
export function compareText(a: string | null | undefined, b: string | null | undefined) {
return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' });
}
export function compareDate(a: string | null | undefined, b: string | null | undefined) {
const aTime = a ? Date.parse(a) : Number.NEGATIVE_INFINITY;
const bTime = b ? Date.parse(b) : Number.NEGATIVE_INFINITY;
return aTime - bTime;
}
export function toISODate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function ausToday(): Date {
const ymd = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Australia/Sydney',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(new Date());
const [y, m, d] = ymd.split('-').map(Number);
return new Date(y, m - 1, d);
}
export function startOfWeekMonday(d: Date): Date {
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const dow = (start.getDay() + 6) % 7;
start.setDate(start.getDate() - dow);
return start;
}
export function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
export function buildConfetti(colors = CONFETTI_COLORS): ConfettiPiece[] {
return Array.from({ length: 42 }, (_, i) => ({
left: Math.random() * 100,
delay: Math.random() * 1.2,
duration: 1 + Math.random() * 0.8,
color: colors[i % colors.length],
rotate: 220 + Math.random() * 360,
drift: (Math.random() - 0.5) * 60,
size: 6 + Math.random() * 6
}));
}
export function isStockEntry(entry: ThroughputEntry): boolean {
return entry.for_stock || (!entry.for_order && /stock/i.test(entry.notes ?? ''));
}
+223 -22
View File
@@ -2,12 +2,21 @@ import {
BadgeDollarSign, BadgeDollarSign,
Calculator, Calculator,
ClipboardPenLine, ClipboardPenLine,
FlaskConical,
Gauge, Gauge,
Layers, Layers,
LayoutDashboard, LayoutDashboard,
Link2,
ListOrdered,
Package,
Plug,
ShieldCheck, ShieldCheck,
ShoppingCart, ShoppingCart,
TrendingUp SlidersHorizontal,
Settings,
Tags,
TrendingUp,
Users
} from 'lucide-svelte'; } from 'lucide-svelte';
import type { ComponentType } from 'svelte'; import type { ComponentType } from 'svelte';
@@ -31,6 +40,18 @@ export type NavItem = {
icon: ComponentType; icon: ComponentType;
moduleKey?: string; moduleKey?: string;
badge?: string; badge?: string;
/**
* Highlight this row only on an exact pathname match instead of a prefix
* match. Needed for parent routes like `/ordering/manage` that are a prefix
* of their siblings (`/ordering/manage/products`).
*/
exact?: boolean;
/**
* Optional third-level submenu. A child with `children` renders as its own
* collapsible row inside a group (e.g. Integrations Xero). The row stays a
* link to its own `href`; a chevron toggles the nested list.
*/
children?: NavItem[];
}; };
export type FooterLink = { export type FooterLink = {
@@ -50,6 +71,14 @@ export type NavGroup = {
label: string; label: string;
icon: ComponentType; icon: ComponentType;
children: NavItem[]; children: NavItem[];
/**
* When set, the group header is itself a link (clicking it navigates here)
* while a separate chevron still toggles the child list. Used by Order
* Management: clicking the header lands on the order queue.
*/
href?: string;
/** Exact-match the header link's active state (see NavItem.exact). */
exact?: boolean;
}; };
/** The rail is a sequence of standalone items and collapsible groups. */ /** The rail is a sequence of standalone items and collapsible groups. */
@@ -62,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',
@@ -96,6 +131,15 @@ export const editorItem: NavItem = {
badge: 'test' badge: 'test'
}; };
export const ingredientsEditorItem: NavItem = {
href: '/ingredients',
label: 'Ingredients Editor',
shortLabel: 'IE',
icon: FlaskConical,
moduleKey: 'products',
badge: 'test'
};
export const reportingItem: NavItem = { export const reportingItem: NavItem = {
href: '/reporting', href: '/reporting',
label: 'Reporting', label: 'Reporting',
@@ -109,8 +153,7 @@ export const throughputItem: NavItem = {
label: 'Throughput', label: 'Throughput',
shortLabel: 'OT', shortLabel: 'OT',
icon: Gauge, icon: Gauge,
moduleKey: 'operations_throughput', moduleKey: 'operations_throughput'
badge: 'test'
}; };
export const orderingItem: NavItem = { export const orderingItem: NavItem = {
@@ -121,6 +164,37 @@ export const orderingItem: NavItem = {
moduleKey: 'ordering' moduleKey: 'ordering'
}; };
/** Third-level submenu under Integrations. Each connected system is its own row. */
export const integrationsChildren: NavItem[] = [
{ href: '/ordering/manage/integrations/xero', label: 'Xero', shortLabel: 'XE', icon: Link2, moduleKey: 'ordering' }
];
/**
* Children of the internal "Order Management" family. The first entry points at
* the management root (`/ordering/manage`) which renders the order queue, so it
* needs `exact` matching to avoid lighting up on its sibling routes. Integrations
* is itself a parent: it links to the integrations landing and expands to its
* connected systems (Xero) as a third sidebar layer.
*/
export const orderingManageChildren: NavItem[] = [
{ href: '/ordering/manage', label: 'Orders', shortLabel: 'OQ', icon: ListOrdered, moduleKey: 'ordering', exact: true },
{ href: '/ordering/manage/products', label: 'Products', shortLabel: 'PR', icon: Package, moduleKey: 'ordering' },
{ href: '/ordering/manage/customers', label: 'Customers', shortLabel: 'CU', icon: Users, moduleKey: 'ordering' },
{ href: '/ordering/manage/pricing', label: 'Pricing', shortLabel: 'PX', icon: Tags, moduleKey: 'ordering' },
{ href: '/ordering/manage/settings', label: 'Settings', shortLabel: 'ST', icon: SlidersHorizontal, moduleKey: 'ordering' },
{ href: '/ordering/manage/integrations', label: 'Integrations', shortLabel: 'IN', icon: Plug, moduleKey: 'ordering', exact: true, children: integrationsChildren }
];
/** The collapsible Order Management family for internal staff. */
export const orderingManageGroup: NavGroup = {
id: 'ordering',
label: 'Order Management',
icon: ShoppingCart,
href: '/ordering/manage',
exact: true,
children: orderingManageChildren
};
export const workingDocumentItems: NavItem[] = [ export const workingDocumentItems: NavItem[] = [
// Mix Master remains available through the existing route and access logic, // Mix Master remains available through the existing route and access logic,
// but is temporarily hidden from the sidebar. // but is temporarily hidden from the sidebar.
@@ -140,6 +214,7 @@ export const clientNavigationItems: NavItem[] = [
productCostingItem, productCostingItem,
throughputItem, throughputItem,
editorItem, editorItem,
ingredientsEditorItem,
accessControlItem accessControlItem
]; ];
@@ -155,8 +230,14 @@ export const baseSearchItems: SearchItem[] = [
{ {
href: '/editor', href: '/editor',
label: 'Open Mix Editor', label: 'Open Mix Editor',
description: 'Edit client, product, and mix naming from one table.', description: 'Edit mix names, status, and ingredients from one table.',
keywords: 'editor products mixes clients names bulk table phf horse manning' keywords: 'editor mixes clients names status ingredients recipe table phf horse manning'
},
{
href: '/ingredients',
label: 'Open Ingredients Editor',
description: 'Curate the raw material ingredients available to mixes.',
keywords: 'ingredients editor raw materials supplier unit kg per unit catalogue mixes'
}, },
{ {
href: '/', href: '/',
@@ -211,15 +292,16 @@ 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;
costing: NavItem[]; costing: NavItem[];
throughput?: NavItem | null; throughput?: NavItem | null;
ordering?: NavItem | null; ordering?: NavEntry | null;
reporting?: NavItem | null; reporting?: NavItem | null;
}): NavEntry[] { }): NavEntry[] {
const entries: NavEntry[] = []; const entries: NavEntry[] = [];
@@ -228,19 +310,19 @@ 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 }
}); });
} }
if (visible.ordering) { if (visible.ordering) {
entries.push({ kind: 'item', item: visible.ordering }); entries.push(visible.ordering);
}
if (visible.throughput) {
entries.push({ kind: 'item', item: visible.throughput });
} }
if (visible.reporting) { if (visible.reporting) {
@@ -250,17 +332,122 @@ export function buildClientNavEntries(visible: {
return entries; return entries;
} }
/** True when any of a group's children matches the current route. */ /** True when a row or any of its nested children matches the current route. */
export function groupHasActiveChild(group: NavGroup, pathname: string) { function itemOrChildActive(item: NavItem, pathname: string): boolean {
return group.children.some((child) => matchesRoute(child.href, pathname)); if (matchesRoute(item.href, pathname, item.exact)) return true;
return item.children?.some((child) => matchesRoute(child.href, pathname, child.exact)) ?? false;
} }
export function matchesRoute(href: string, pathname: string) { /** True when any of a group's children (or grandchildren) matches the route. */
return href === '/' ? pathname === '/' : pathname.startsWith(href); export function groupHasActiveChild(group: NavGroup, pathname: string) {
return group.children.some((child) => itemOrChildActive(child, pathname));
}
export function matchesRoute(href: string, pathname: string, exact = false) {
if (href === '/') return pathname === '/';
if (exact) return pathname === href;
return pathname.startsWith(href);
}
/**
* Resolve the deepest Order Management section row for a path, descending into
* third-level submenus (Integrations Xero) so headers and breadcrumbs name the
* actual page rather than the parent. Returns null for the management root.
*/
export function findOrderingSection(pathname: string): NavItem | null {
for (const child of orderingManageChildren) {
// Check grandchildren first so a nested page (Xero) wins over its parent.
for (const grandchild of child.children ?? []) {
if (matchesRoute(grandchild.href, pathname, grandchild.exact)) return grandchild;
}
if (matchesRoute(child.href, pathname, child.exact)) return child;
}
return null;
} }
export function pageTitle(pathname: string) { export function pageTitle(pathname: string) {
return clientNavigationItems.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard'; return pageMeta(pathname).title;
}
export function pageCategory(pathname: string) {
return pageMeta(pathname).category;
}
export function pageMeta(pathname: string): PageMeta {
if (pathname === '/') {
return { title: 'Dashboard', category: 'Overview', icon: dashboardItem.icon };
}
if (pathname.startsWith('/ordering/manage')) {
const section = findOrderingSection(pathname);
return {
title: section?.label ?? 'Orders',
category: 'Order Management',
icon: section?.icon ?? orderingManageGroup.icon
};
}
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[] {
@@ -297,6 +484,20 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
return base; return base;
} }
if (pathname.startsWith('/ordering/manage')) {
const section = findOrderingSection(pathname);
if (!section || section.href === '/ordering/manage') {
return [...crumbs, { label: 'Order Management' }];
}
const chain: Crumb[] = [...crumbs, { label: 'Order Management', href: '/ordering/manage' }];
// When the section is a nested grandchild (e.g. Xero), include its parent
// (Integrations) as an intermediate crumb.
const parent = orderingManageChildren.find((c) => c.children?.includes(section));
if (parent) chain.push({ label: parent.label, href: parent.href });
chain.push({ label: section.label });
return chain;
}
const sectionMap: Record<string, string> = { const sectionMap: Record<string, string> = {
'/raw-materials': 'Raw Materials', '/raw-materials': 'Raw Materials',
'/product-costing': 'Product Costing', '/product-costing': 'Product Costing',
+63
View File
@@ -0,0 +1,63 @@
// Shared formatting helpers for the ordering management console. Kept tiny and
// dependency-free so each split route page (orders, products, pricing, …) can
// import them instead of re-declaring the same money/label functions.
const AUD = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
/** Format a value as AUD, or an em dash when null/undefined. */
export function money(value: number | null | undefined): string {
if (value == null) return '—';
return AUD.format(value);
}
/** Turn a snake_case status/category key into Title Case for display. */
export function label(value: string): string {
return value.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Order workflow statuses, in the order they typically progress. */
export const ORDER_STATUSES = [
'submitted',
'under_review',
'confirmed',
'sent_to_xero',
'in_production',
'ready_for_pickup',
'dispatched',
'completed',
'cancelled'
] as const;
/**
* Map an order / customer status to a pill tone class so states are scannable
* at a glance: warn = needs attention, info = in flight, pos = done/active,
* danger = cancelled. Unknown values fall back to the neutral base pill.
*/
const STATUS_TONE: Record<string, string> = {
submitted: 'warn',
under_review: 'warn',
confirmed: 'pos',
completed: 'pos',
active: 'pos',
cancelled: 'danger',
disabled: 'danger',
suspended: 'danger',
sent_to_xero: 'info',
in_production: 'info',
ready_for_pickup: 'info',
dispatched: 'info'
};
export function statusTone(value: string): string {
return STATUS_TONE[value] ?? '';
}
/** Catalogue product categories. */
export const PRODUCT_CATEGORIES = [
'grains',
'premixed',
'bags',
'bulk_loads',
'custom_blends',
'services'
] as const;
+438
View File
@@ -0,0 +1,438 @@
/*
* Shared styles for the Order Management family. Imported once by
* routes/ordering/manage/+layout.svelte (so the rules are global) but every
* selector is scoped under `.manage-shell` the layout wrapper that contains
* all child route markup so nothing leaks into the rest of the app.
*
* Everything resolves from the design tokens in $lib/styles/theme.css so the
* console re-themes (light/dark) automatically and stays visually consistent
* with the rest of the app. No hard-coded colours.
*/
.manage-shell h2 { margin: 0 0 0.75rem; font-size: 1.02rem; font-weight: 700; letter-spacing: -0.01em; }
.manage-shell h3 { margin: 0 0 0.45rem; font-size: 0.86rem; font-weight: 700; color: var(--color-text-secondary); }
.manage-shell .mt { margin-top: 1.15rem; }
.manage-shell .muted { color: var(--color-text-muted); font-size: 0.84rem; margin: 0 0 0.65rem; }
.manage-shell .muted a { color: var(--color-brand); font-weight: 600; }
.manage-shell .muted a:hover { text-decoration: underline; }
.manage-shell .surface-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-panel);
background: var(--color-bg-surface);
padding: var(--space-card);
}
.manage-shell .split {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
/* ── Tables: compact console rows, divider-separated ────────── */
.manage-shell table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.manage-shell th,
.manage-shell td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--color-divider); }
.manage-shell tr:last-child td { border-bottom: none; }
.manage-shell th {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
border-bottom-color: var(--color-border);
}
.manage-shell tbody tr { transition: background-color 140ms cubic-bezier(0.22, 1, 0.36, 1); }
.manage-shell tbody tr:hover { background: var(--color-surface-hover); }
.manage-shell tbody tr.selected { background: var(--color-surface-selected); }
.manage-shell table.clickable tbody tr { cursor: pointer; }
/* ── Status pills: neutral by default, tinted by tone ──────── */
.manage-shell .pill {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 0.18rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
text-transform: capitalize;
white-space: nowrap;
color: var(--color-text-secondary);
background: color-mix(in srgb, var(--panel-soft) 70%, var(--color-bg-surface));
}
.manage-shell .pill.pos { color: var(--color-success-text); background: var(--color-success-tint); }
.manage-shell .pill.warn { color: var(--color-warning-text); background: var(--color-warning-tint); }
.manage-shell .pill.info { color: var(--color-info); background: var(--color-info-tint); }
.manage-shell .pill.danger { color: var(--color-error); background: color-mix(in srgb, var(--color-error) 14%, var(--color-bg-surface)); }
.manage-shell .tag {
margin-left: 0.4rem;
font-size: 0.64rem;
font-weight: 600;
padding: 0.08rem 0.4rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--color-warning-tint);
color: var(--color-warning-text);
}
.manage-shell .empty { color: var(--color-text-muted); font-size: 0.85rem; margin: 0.2rem 0; }
/* ── Forms ──────────────────────────────────────────────────── */
.manage-shell .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem 0.65rem; align-items: end; }
.manage-shell .form-grid .full { grid-column: 1 / -1; }
.manage-shell .form-grid label,
.manage-shell .form-row label { display: grid; gap: 0.28rem; font-size: 0.76rem; font-weight: 600; color: var(--color-text-secondary); }
.manage-shell .form-row { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; margin-bottom: 0.6rem; }
.manage-shell .form-row input,
.manage-shell .form-row select,
.manage-shell .form-grid input,
.manage-shell .form-grid select,
.manage-shell .actions select,
.manage-shell .inline,
.manage-shell .ovr {
padding: 0.5rem 0.6rem;
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 input:focus,
.manage-shell select: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);
}
.manage-shell input:disabled,
.manage-shell select:disabled { opacity: 0.55; cursor: not-allowed; }
.manage-shell .check { display: flex; flex-direction: row; align-items: center; gap: 0.45rem; font-weight: 600; color: var(--color-text-secondary); }
.manage-shell .check input { accent-color: var(--color-brand); width: 1rem; height: 1rem; }
.manage-shell .inline-label { flex-direction: row; align-items: center; gap: 0.5rem; }
.manage-shell .inline { width: 6.5rem; padding: 0.4rem 0.5rem; }
.manage-shell .ovr { width: 5.5rem; padding: 0.4rem 0.5rem; }
/* ── Buttons ────────────────────────────────────────────────── */
.manage-shell .primary,
.manage-shell .secondary {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.3rem;
border-radius: var(--radius-control);
padding: 0.5rem 0.95rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: background-color 150ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.manage-shell .primary { background: var(--color-brand); border-color: var(--color-brand); color: var(--color-on-brand); }
.manage-shell .primary:hover:not(:disabled) { background: var(--color-brand-hover); border-color: var(--color-brand-hover); }
.manage-shell .secondary { background: var(--color-bg-surface); border-color: var(--color-border); color: var(--color-text-primary); }
.manage-shell .secondary:hover:not(:disabled) { background: var(--color-surface-hover); }
.manage-shell .primary:disabled,
.manage-shell .secondary:disabled { opacity: 0.55; cursor: not-allowed; }
.manage-shell .link {
background: none;
border: none;
color: var(--color-brand);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
padding: 0;
}
.manage-shell .link:hover { text-decoration: underline; }
/* ── Order / customer detail ────────────────────────────────── */
.manage-shell .lines td { font-size: 0.82rem; }
.manage-shell .detail-total {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.65rem 0.05rem;
border-top: 1px solid var(--color-border);
margin: 0.5rem 0 0.85rem;
font-size: 0.86rem;
color: var(--color-text-secondary);
}
.manage-shell .detail-total strong { font-size: 1.05rem; color: var(--color-text-primary); }
.manage-shell .actions { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; }
.manage-shell .history { margin-top: 0.95rem; font-size: 0.8rem; color: var(--color-text-secondary); }
.manage-shell .history summary { cursor: pointer; font-weight: 600; color: var(--color-text-secondary); }
.manage-shell .history summary:hover { color: var(--color-text-primary); }
.manage-shell .history ul { margin: 0.5rem 0 0; padding-left: 1.1rem; display: grid; gap: 0.3rem; }
.manage-shell .mini { list-style: none; margin: 0.3rem 0 0.7rem; padding: 0; display: grid; gap: 0.4rem; font-size: 0.82rem; }
.manage-shell .mini li {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.55rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--panel-soft);
}
.manage-shell .visibility li { justify-content: flex-start; }
/* ── Card header: title on the left, primary action on the right ── */
.manage-shell .card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.85rem;
margin-bottom: 0.85rem;
}
.manage-shell .card-head h2 { margin: 0; }
/* ── Modal ──────────────────────────────────────────────────── */
.manage-shell .modal-backdrop {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: start center;
padding: 10vh 1rem 1rem;
background: color-mix(in srgb, var(--color-text-primary) 32%, transparent);
backdrop-filter: blur(8px);
}
.manage-shell .modal {
width: min(30rem, 100%);
border: 1px solid var(--color-border);
border-radius: var(--radius-panel);
background: var(--color-bg-surface);
padding: var(--space-card);
}
.manage-shell .modal.wide { width: min(40rem, 100%); }
.manage-shell .modal h2 { margin: 0 0 1rem; }
.manage-shell .modal .actions { justify-content: flex-end; margin-top: 1.15rem; }
/* Console primitives: shared master/detail vocabulary
* Reusable across the management console (products, orders, ) so each page
* reads the same. Everything resolves from design tokens. */
/* Two-pane master/detail: a list on the left, a sticky detail on the right. */
.manage-shell .console-split {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.7fr);
gap: 1rem;
align-items: start;
}
.manage-shell .console-split > .detail {
position: sticky;
top: 1rem;
}
/* Count chip beside a heading. */
.manage-shell .count {
margin-left: 0.3rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* Full-width search field above a list. */
.manage-shell .list-search {
width: 100%;
margin-bottom: 0.7rem;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-input-bg);
color: var(--color-text-primary);
font: inherit;
transition: border-color 140ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 140ms cubic-bezier(0.22, 1, 0.36, 1), background-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
}
.manage-shell .list-search:focus {
outline: none;
border-color: var(--color-brand);
background: var(--color-bg-surface);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
}
/* Inline reveal panel for create forms (replaces create modals). */
.manage-shell .create-panel {
margin-bottom: 0.85rem;
padding: 0.95rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--panel-soft);
}
.manage-shell .create-panel .actions { justify-content: flex-end; margin-top: 0.85rem; }
/* Two-line identity cell: bold name over a muted sub-line. */
.manage-shell .id-cell { display: grid; gap: 0.12rem; min-width: 0; }
.manage-shell .id-name { font-size: 0.88rem; font-weight: 600; color: var(--color-text-primary); }
.manage-shell .id-sub { font-size: 0.74rem; color: var(--color-text-muted); }
/* Detail header: title block left, status + actions right. */
.manage-shell .detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
padding-bottom: 0.95rem;
margin-bottom: 0.3rem;
border-bottom: 1px solid var(--color-divider);
}
.manage-shell .detail-title h2 { margin: 0; font-size: 1.2rem; }
.manage-shell .eyebrow {
margin: 0 0 0.15rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.manage-shell .detail-head-actions { display: inline-flex; align-items: center; gap: 0.6rem; flex-shrink: 0; }
/* Square icon button (close, etc.). */
.manage-shell .icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.3rem;
height: 2.3rem;
flex-shrink: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: background-color 150ms cubic-bezier(0.22, 1, 0.36, 1),
color 150ms cubic-bezier(0.22, 1, 0.36, 1), border-color 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.manage-shell .icon-btn:hover { background: var(--color-surface-hover); color: var(--color-text-primary); }
.manage-shell .icon-btn svg { display: block; }
/* Section sub-header inside a detail panel. */
.manage-shell .section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.65rem;
}
.manage-shell .section-head h3 { margin: 0; }
/* Inline meta chips (PO, fulfilment, dates). */
.manage-shell .meta { display: flex; flex-wrap: wrap; gap: 0.45rem 1.1rem; margin: 0.2rem 0 1rem; }
.manage-shell .meta-item { display: grid; gap: 0.1rem; }
.manage-shell .meta-item span {
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.manage-shell .meta-item strong { font-size: 0.84rem; font-weight: 600; color: var(--color-text-primary); }
/* Centered empty state for an unselected detail pane. */
.manage-shell .empty-detail {
display: grid;
place-items: center;
text-align: center;
gap: 0.5rem;
padding: 3rem 1.5rem;
}
.manage-shell .empty-detail h2 { margin: 0; }
.manage-shell .empty-detail .muted { max-width: 28rem; margin: 0; }
.manage-shell .empty-icon {
display: grid;
place-items: center;
width: 3.25rem;
height: 3.25rem;
border-radius: 0.95rem;
background: var(--color-brand-tint);
color: var(--color-brand);
}
/* Status pill that reads as Active / Disabled from a boolean. */
.manage-shell .pill.muted-pill {
color: var(--color-text-muted);
background: color-mix(in srgb, var(--panel-soft) 70%, var(--color-bg-surface));
}
/* Workspace shell: a pinned header + tabs over a scrolling body
* Gives a detail pane the feel of an anchored management surface. The head
* (title, status, tabs, primary actions) stays put while the body scrolls,
* capped to the viewport so the list beside it is always reachable. */
.manage-shell .workspace {
padding: 0;
display: flex;
flex-direction: column;
max-height: calc(100vh - 2rem);
overflow: hidden;
}
.manage-shell .workspace-head {
flex-shrink: 0;
padding: var(--space-card) var(--space-card) 0;
border-bottom: 1px solid var(--color-divider);
}
.manage-shell .workspace-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--space-card);
}
.manage-shell .workspace-tabs {
display: flex;
gap: 0.1rem;
margin-top: 0.9rem;
overflow-x: auto;
scrollbar-width: none;
}
.manage-shell .workspace-tabs::-webkit-scrollbar { display: none; }
.manage-shell .workspace-tab {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.7rem;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--color-text-muted);
font: inherit;
font-size: 0.83rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: color 140ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
}
.manage-shell .workspace-tab:hover { color: var(--color-text-primary); }
.manage-shell .workspace-tab[aria-selected='true'] {
color: var(--color-brand);
border-bottom-color: var(--color-brand);
}
.manage-shell .workspace-tab svg { display: block; }
.manage-shell .workspace-tab .tab-count {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.manage-shell .workspace-tab[aria-selected='true'] .tab-count { color: var(--color-brand); }
/* Vertical rhythm for stacked sections inside a workspace body. */
.manage-shell .ws-section + .ws-section { margin-top: 1.4rem; }
@media (max-width: 1000px) {
.manage-shell .split,
.manage-shell .console-split,
.manage-shell .form-grid { grid-template-columns: 1fr; }
.manage-shell .console-split > .detail { position: static; }
.manage-shell .workspace { max-height: none; }
}
+68 -19
View File
@@ -46,18 +46,20 @@
--color-text-secondary: oklch(0.45 0.008 240); --color-text-secondary: oklch(0.45 0.008 240);
--color-text-muted: oklch(0.6 0.01 240); --color-text-muted: oklch(0.6 0.01 240);
/* Sidebar: light monochrome rail with the current item shown as the /* Sidebar: deep-green rail matching the customer ordering portal
selected pill. Shared across themes so navigation stays consistent. */ (CustomerPortalShell). The current item shows as a white pill with
--sidebar-bg: oklch(0.985 0.001 240); green text. Held constant across light/dark themes so internal staff
--sidebar-hover: oklch(0.952 0.003 240); and customers see the same navigation styling. */
--sidebar-active-bg: #3290d9; --sidebar-bg: #1f3a2c;
--sidebar-active-text: var(--color-on-brand); --sidebar-hover: rgba(255, 255, 255, 0.08);
--sidebar-border: oklch(0.9 0.004 240); --sidebar-active-bg: #ffffff;
--sidebar-text: oklch(0.34 0.006 240); --sidebar-active-text: #1f3a2c;
--sidebar-text-strong: oklch(0.16 0.004 240); --sidebar-border: rgba(255, 255, 255, 0.12);
--sidebar-text-muted: oklch(0.56 0.008 240); --sidebar-text: rgba(231, 239, 233, 0.85);
--sidebar-icon: oklch(0.42 0.006 240); --sidebar-text-strong: #ffffff;
--sidebar-logo-bg: oklch(0.98 0.003 240); --sidebar-text-muted: rgba(231, 239, 233, 0.6);
--sidebar-icon: rgba(231, 239, 233, 0.8);
--sidebar-logo-bg: rgba(255, 255, 255, 0.1);
/* ── Semantic ───────────────────────────────────────────── */ /* ── Semantic ───────────────────────────────────────────── */
--color-success: oklch(0.66 0.16 162); /* emerald, cohesive with accent */ --color-success: oklch(0.66 0.16 162); /* emerald, cohesive with accent */
@@ -128,19 +130,20 @@
--color-border: oklch(0.32 0.006 240); --color-border: oklch(0.32 0.006 240);
--color-divider: oklch(0.28 0.005 240); --color-divider: oklch(0.28 0.005 240);
/* Sidebar: dark rail tuned to the content theme so it stops /* Sidebar: in dark mode the rail drops the deep-green and joins the
rendering as a bright light strip in dark mode. Active item keeps neutral dark surfaces, sitting a touch below the app canvas so it
the blue pill from light mode. */ reads as a distinct rail. The active item becomes a green-tinted pill
with bright brand text rather than the light-mode white chip. */
--sidebar-bg: oklch(0.2 0.005 240); --sidebar-bg: oklch(0.2 0.005 240);
--sidebar-hover: oklch(0.27 0.006 240); --sidebar-hover: oklch(0.27 0.006 240);
--sidebar-active-bg: #3290d9; --sidebar-active-bg: color-mix(in srgb, var(--color-brand) 22%, var(--color-bg-surface));
--sidebar-active-text: var(--color-on-brand); --sidebar-active-text: oklch(0.9 0.07 162);
--sidebar-border: oklch(0.3 0.006 240); --sidebar-border: oklch(0.3 0.006 240);
--sidebar-text: oklch(0.78 0.006 240); --sidebar-text: oklch(0.78 0.006 240);
--sidebar-text-strong: oklch(0.96 0.003 240); --sidebar-text-strong: oklch(0.96 0.003 240);
--sidebar-text-muted: oklch(0.6 0.008 240); --sidebar-text-muted: oklch(0.6 0.008 240);
--sidebar-icon: oklch(0.68 0.008 240); --sidebar-icon: oklch(0.72 0.006 240);
--sidebar-logo-bg: oklch(0.26 0.006 240); --sidebar-logo-bg: rgba(255, 255, 255, 0.08);
/* ── Text (neutral) ─────────────────────────────────────── */ /* ── Text (neutral) ─────────────────────────────────────── */
--color-text-primary: oklch(0.96 0.003 240); --color-text-primary: oklch(0.96 0.003 240);
@@ -560,3 +563,49 @@ a {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ============================================================================
Tooltip (rendered to <body> by the use:tooltip action)
----------------------------------------------------------------------------
Auto-inverts via the text/surface tokens: a near-black bubble in light mode,
a near-white bubble in dark mode readable contrast in both.
============================================================================ */
.app-tooltip {
position: fixed;
z-index: 1000;
padding: 0.36rem 0.55rem;
border-radius: 0.5rem;
background: var(--color-text-primary);
color: var(--color-bg-surface);
font-size: 0.76rem;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.005em;
white-space: normal;
overflow-wrap: break-word;
text-align: center;
width: max-content;
max-width: min(16rem, calc(100vw - 1rem));
pointer-events: none;
box-shadow: 0 6px 20px -6px rgba(0, 0, 0, 0.35), 0 2px 6px -2px rgba(0, 0, 0, 0.25);
opacity: 0;
transform: translateY(-2px);
transition: opacity 130ms ease, transform 130ms ease;
}
.app-tooltip[data-placement='top'] {
transform: translateY(2px);
}
.app-tooltip.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.app-tooltip {
transition: opacity 130ms ease;
transform: none;
}
}
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
import { ChevronDown, ChevronsUpDown, ChevronUp } from 'lucide-svelte';
import type { TableController } from './table.svelte';
let {
label,
column,
controller
}: {
label: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
controller: TableController<any>;
column: string;
} = $props();
const active = $derived(controller.sortKey === column);
</script>
<button
type="button"
class="sort-header"
class:active
aria-label={`Sort by ${label}${active ? (controller.sortDir === 'asc' ? ', ascending' : ', descending') : ''}`}
onclick={() => controller.toggleSort(column)}
>
<span>{label}</span>
{#if active}
{#if controller.sortDir === 'asc'}
<ChevronUp size={13} strokeWidth={2.6} />
{:else}
<ChevronDown size={13} strokeWidth={2.6} />
{/if}
{:else}
<ChevronsUpDown size={13} strokeWidth={2} />
{/if}
</button>
<style>
.sort-header {
display: inline-flex;
align-items: center;
gap: 0.28rem;
width: 100%;
min-height: 26px;
padding: 0;
border: 0;
background: transparent;
/* Inherit the .log-head typography (size/weight/transform/letter-spacing). */
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
text-align: left;
cursor: pointer;
user-select: none;
}
.sort-header :global(svg) {
flex-shrink: 0;
opacity: 0.4;
transition: opacity 120ms ease;
}
.sort-header:hover {
color: var(--color-text-primary);
}
.sort-header:hover :global(svg) {
opacity: 0.7;
}
.sort-header.active {
color: var(--color-brand);
}
.sort-header.active :global(svg) {
opacity: 1;
}
.sort-header:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
border-radius: 0.3rem;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
// Shared client-side table controller: sorting + pagination over an already
// filtered row set. Both the Mix Editor and Ingredients Editor feed their
// filtered `visibleRows` in and render `rows` out, so the two surfaces stay
// behaviourally identical. Keeping the page math here (rather than ad-hoc
// `$effect`s per page) means navigation can't get clamped back to page 1.
export type SortDir = 'asc' | 'desc';
export type SortValue = string | number | boolean | null | undefined;
export type Accessor<T> = (row: T) => SortValue;
const PAGE_SIZES = [10, 25, 50, 100] as const;
function isEmpty(value: SortValue): boolean {
return value === null || value === undefined || value === '';
}
function compareNonEmpty(a: SortValue, b: SortValue): number {
if (typeof a === 'number' && typeof b === 'number') return a - b;
if (typeof a === 'boolean' && typeof b === 'boolean') {
return a === b ? 0 : a ? -1 : 1;
}
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
export class TableController<T> {
readonly pageSizes = PAGE_SIZES;
page = $state(1);
pageSize = $state(25);
sortKey = $state<string | null>(null);
sortDir = $state<SortDir>('asc');
private readonly source: () => readonly T[];
private readonly accessors: Record<string, Accessor<T>>;
constructor(source: () => readonly T[], accessors: Record<string, Accessor<T>> = {}) {
this.source = source;
this.accessors = accessors;
}
readonly sorted = $derived.by<readonly T[]>(() => {
const rows = this.source();
const accessor = this.sortKey ? this.accessors[this.sortKey] : undefined;
if (!accessor) return rows;
const dir = this.sortDir === 'asc' ? 1 : -1;
// Empties always sort last, regardless of direction.
return [...rows].sort((a, b) => {
const av = accessor(a);
const bv = accessor(b);
const aEmpty = isEmpty(av);
const bEmpty = isEmpty(bv);
if (aEmpty || bEmpty) {
if (aEmpty && bEmpty) return 0;
return aEmpty ? 1 : -1;
}
return compareNonEmpty(av, bv) * dir;
});
});
readonly total = $derived(this.sorted.length);
readonly totalPages = $derived(Math.max(1, Math.ceil(this.total / this.pageSize)));
// The page we actually show. Derived clamping means shrinking the result set
// (via filters or a larger page size) can never strand the view on an empty page.
readonly currentPage = $derived(Math.min(Math.max(1, this.page), this.totalPages));
readonly pageStart = $derived(this.total === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1);
readonly pageEnd = $derived(Math.min(this.total, this.currentPage * this.pageSize));
readonly rows = $derived(this.sorted.slice(this.pageStart === 0 ? 0 : this.pageStart - 1, this.pageEnd));
readonly canPrev = $derived(this.currentPage > 1);
readonly canNext = $derived(this.currentPage < this.totalPages);
toggleSort(key: string): void {
if (!this.accessors[key]) return;
if (this.sortKey === key) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortDir = 'asc';
}
this.page = 1;
}
setPage(next: number): void {
this.page = Math.min(Math.max(1, next), this.totalPages);
}
next(): void {
this.setPage(this.currentPage + 1);
}
prev(): void {
this.setPage(this.currentPage - 1);
}
reset(): void {
this.page = 1;
}
}
+127
View File
@@ -99,6 +99,7 @@ export type MixCalculatorLine = {
required_kg: number; required_kg: number;
mix_percentage: number; mix_percentage: number;
unit: string; unit: string;
rounding_decimals?: number;
sort_order: number; sort_order: number;
}; };
@@ -302,10 +303,74 @@ 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;
notes?: string | null; notes?: string | null;
visible?: boolean;
};
export type EditorMixRow = {
id: number;
tenant_id: string;
client_name: string;
name: string;
visible: boolean;
product_count: number;
visible_product_count: number;
notes: string | null;
};
export type EditorMixIngredient = {
id: number;
raw_material_id: number;
raw_material_name: string;
quantity_kg: number;
notes: string | null;
};
export type EditorMixFormula = {
id: number;
tenant_id: string;
client_name: string;
name: string;
ingredients: EditorMixIngredient[];
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 = {
@@ -328,6 +393,32 @@ export type EditorProductFormula = {
total_kg: number; total_kg: number;
}; };
export type EditorIngredientRow = {
id: number;
name: string;
supplier: string | null;
unit_of_measure: string;
kg_per_unit: number;
status: string;
rounding_decimals: number;
notes: string | null;
cost_per_kg: number | null;
usage_count: number;
created_at: string;
};
export type EditorIngredientCreateInput = {
name: string;
supplier?: string | null;
unit_of_measure: string;
kg_per_unit: number;
status?: string;
rounding_decimals?: number;
notes?: string | null;
};
export type EditorIngredientUpdateInput = Partial<EditorIngredientCreateInput>;
export type Scenario = { export type Scenario = {
id: number; id: number;
name: string; name: string;
@@ -616,6 +707,15 @@ export type ThroughputEntryCreateInput = {
notes?: string | null; notes?: string | null;
}; };
export type ThroughputEntryUpdateInput = Partial<ThroughputEntryCreateInput>;
export type ThroughputImportResult = {
entries_imported: number;
entries_skipped: number;
products_created: number;
errors: string[];
};
export type ThroughputEntryListParams = { export type ThroughputEntryListParams = {
date_from?: string; date_from?: string;
date_to?: string; date_to?: string;
@@ -745,6 +845,8 @@ export type OrderingCustomer = {
user_count: number; user_count: number;
price_list_id: number | null; price_list_id: number | null;
discount_percent: number; discount_percent: number;
xero_contact_id?: string | null;
xero_contact_name?: string | null;
created_at: string; created_at: string;
}; };
@@ -793,6 +895,7 @@ export type OrderingNotificationSettings = {
export type XeroStatus = { export type XeroStatus = {
connection: { configured: boolean; mode: string; base_url: string; checked_at: string; missing_env: string[] }; connection: { configured: boolean; mode: string; base_url: string; checked_at: string; missing_env: string[] };
contact_links: { linked: number; total: number; unlinked: number };
recent_syncs: { recent_syncs: {
id: number; id: number;
order_id: number; order_id: number;
@@ -802,3 +905,27 @@ export type XeroStatus = {
created_at: string; created_at: string;
}[]; }[];
}; };
export type XeroContact = {
contact_id: string;
name: string;
email: string | null;
status: string;
};
export type XeroContactList = {
contacts: XeroContact[];
stubbed: boolean;
};
export type XeroContactLinkRow = {
customer_id: number;
customer_name: string;
client_code: string;
linked: boolean;
xero_contact_id: string | null;
xero_contact_name: string | null;
xero_contact_email: string | null;
last_synced_at: string | null;
suggested_contact_id: string | null;
};
+1
View File
@@ -264,6 +264,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
if (pathname.startsWith('/product-costing')) return canOpenProductCosting(session); if (pathname.startsWith('/product-costing')) return canOpenProductCosting(session);
if (pathname.startsWith('/products')) return canOpenProducts(session); if (pathname.startsWith('/products')) return canOpenProducts(session);
if (pathname.startsWith('/editor')) return canOpenEditor(session); if (pathname.startsWith('/editor')) return canOpenEditor(session);
if (pathname.startsWith('/ingredients')) return canOpenEditor(session);
if (pathname.startsWith('/scenarios')) return canOpenScenarios(session); if (pathname.startsWith('/scenarios')) return canOpenScenarios(session);
if (pathname.startsWith('/reporting')) return canOpenReporting(session); if (pathname.startsWith('/reporting')) return canOpenReporting(session);
if (pathname.startsWith('/settings')) return canOpenSettings(session); if (pathname.startsWith('/settings')) return canOpenSettings(session);
+3 -3
View File
@@ -7,7 +7,7 @@
import '$lib/theme'; import '$lib/theme';
import { beforeNavigate, afterNavigate } from '$app/navigation'; import { beforeNavigate, afterNavigate } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import ClientShell from '$lib/components/ClientShell.svelte'; import AppShell from '$lib/components/AppShell.svelte';
import CustomerPortalShell from '$lib/components/CustomerPortalShell.svelte'; import CustomerPortalShell from '$lib/components/CustomerPortalShell.svelte';
import Toast from '$lib/components/Toast.svelte'; import Toast from '$lib/components/Toast.svelte';
import { clientSession } from '$lib/session'; import { clientSession } from '$lib/session';
@@ -50,9 +50,9 @@
{@render children()} {@render children()}
</CustomerPortalShell> </CustomerPortalShell>
{:else} {:else}
<ClientShell> <AppShell>
{@render children()} {@render children()}
</ClientShell> </AppShell>
{/if} {/if}
<Toast /> <Toast />
+9 -1
View File
@@ -1,7 +1,7 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session'; import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenDashboard, getWorkspaceHomeHref } from '$lib/workspace-access'; import { canOpenDashboard, getWorkspaceHomeHref, isCustomerPortalSession } from '$lib/workspace-access';
import type { DashboardSummary } from '$lib/types'; import type { DashboardSummary } from '$lib/types';
const EMPTY_SUMMARY: DashboardSummary = { const EMPTY_SUMMARY: DashboardSummary = {
@@ -22,6 +22,14 @@ export function load({ fetch }) {
} }
const session = getStoredClientSession(); const session = getStoredClientSession();
// B2B ordering customers must never land on (or flash) the internal
// dashboard — even if their account happens to carry the dashboard module.
// Send them straight to their catalogue before any dashboard data loads.
if (isCustomerPortalSession(session)) {
throw redirect(307, '/ordering');
}
if (!canOpenDashboard(session)) { if (!canOpenDashboard(session)) {
throw redirect(307, getWorkspaceHomeHref(session)); throw redirect(307, getWorkspaceHomeHref(session));
} }
@@ -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,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -15,7 +15,7 @@ export async function load({ fetch }) {
try { try {
const [rows, rawMaterials] = await Promise.all([ const [rows, rawMaterials] = await Promise.all([
api.editorProducts({ limit: 1000 }, fetch), api.editorMixes({ limit: 1000 }, fetch),
api.rawMaterials(fetch) api.rawMaterials(fetch)
]); ]);
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { ingredients: [] };
}
const session = getStoredClientSession();
if (!canOpenEditor(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const ingredients = await api.editorIngredients(fetch);
return { ingredients };
} catch {
return { ingredients: [] };
}
}
-11
View File
@@ -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; }
@@ -0,0 +1,15 @@
<script lang="ts">
import '$lib/ordering/manage.css';
let { children } = $props();
</script>
<!-- Section navigation lives in the primary left rail (and the mobile drawer),
so the console pages don't repeat it inline. -->
<div class="manage-shell">
{@render children()}
</div>
<style>
.manage-shell { display: grid; gap: 1rem; }
</style>
@@ -0,0 +1,19 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
// Single access guard for the whole Order Management family. Each child route
// (orders, products, customers, pricing, settings, integrations) loads its own
// data; this layout just keeps non-managers out of all of them in one place.
export async function load() {
if (!hasStoredClientSession()) {
return {};
}
const session = getStoredClientSession();
if (!canManageOrdering(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
return {};
}
+165 -496
View File
@@ -1,59 +1,29 @@
<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 type { import { tooltip } from '$lib/actions/tooltip';
CatalogueProduct, import { money, label, statusTone, ORDER_STATUSES } from '$lib/ordering/format';
CustomerPricing, import type { Order } from '$lib/types';
CustomerVisibilityRow,
Order,
OrderingCustomer,
OrderingCustomerUser,
OrderingNotificationSettings,
XeroStatus
} from '$lib/types';
let { data } = $props(); let { data } = $props();
type Tab = 'orders' | 'products' | 'customers' | 'settings';
let tab = $state<Tab>('orders');
// Mutable local copies of the loader data (refresh helpers reassign these).
// Seeded from `data` via an effect so navigation re-syncs without the
// "only captures the initial value" warning.
let orders = $state<Order[]>([]); let orders = $state<Order[]>([]);
let products = $state<CatalogueProduct[]>([]);
let customers = $state<OrderingCustomer[]>([]);
let xero = $state<XeroStatus | null>(null);
$effect(() => { $effect(() => {
orders = data.orders ?? []; orders = data.orders ?? [];
products = data.products ?? [];
customers = data.customers ?? [];
xero = data.xero ?? null;
}); });
const STATUSES = [ let listQuery = $state('');
'submitted', const filteredOrders = $derived.by(() => {
'under_review', const q = listQuery.trim().toLowerCase();
'confirmed', if (!q) return orders;
'sent_to_xero', return orders.filter((o) => {
'in_production', const ref = (o.order_number ?? `#${o.id}`).toLowerCase();
'ready_for_pickup', return ref.includes(q) || (o.customer_name ?? '').toLowerCase().includes(q);
'dispatched', });
'completed', });
'cancelled'
];
const CATEGORIES = ['grains', 'premixed', 'bags', 'bulk_loads', 'custom_blends', 'services'];
function money(v: number | null | undefined) {
if (v == null) return '—';
return new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }).format(v);
}
function label(s: string) {
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// --- Orders ---------------------------------------------------------------
let selectedOrder = $state<Order | null>(null); let selectedOrder = $state<Order | null>(null);
let statusChoice = $state(''); let statusChoice = $state('');
@@ -65,6 +35,13 @@
toast.error(e instanceof Error ? e.message : 'Could not load order.'); toast.error(e instanceof Error ? e.message : 'Could not load order.');
} }
} }
function closeOrder() {
selectedOrder = null;
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();
@@ -84,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) {
@@ -112,480 +92,169 @@
toast.error(e instanceof Error ? e.message : 'Could not reopen.'); toast.error(e instanceof Error ? e.message : 'Could not reopen.');
} }
} }
// --- Products -------------------------------------------------------------
let newProduct = $state<Record<string, any>>({ name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true });
async function refreshProducts() {
try {
products = await api.orderingAdmin.products();
} catch {}
}
async function createProduct() {
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
try {
await api.orderingAdmin.createProduct({ ...newProduct, base_price: newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price) });
toast.success('Product created.');
newProduct = { name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true };
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create product.');
}
}
async function toggleProductActive(p: CatalogueProduct) {
try {
await api.orderingAdmin.updateProduct(p.id, { active: !p.active });
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function saveProductPrice(p: CatalogueProduct, value: string) {
try {
await api.orderingAdmin.updateProduct(p.id, { base_price: value === '' ? null : Number(value) });
toast.success('Base price updated.');
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
// --- Customers ------------------------------------------------------------
let newCustomer = $state({ name: '', client_code: '' });
let selectedCustomer = $state<OrderingCustomer | null>(null);
let custUsers = $state<OrderingCustomerUser[]>([]);
let custPricing = $state<CustomerPricing | null>(null);
let custVisibility = $state<CustomerVisibilityRow[]>([]);
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
let discountInput = $state(0);
let newPrice = $state<Record<string, any>>({ product_id: '', unit_price: '', rule_type: 'fixed' });
async function refreshCustomers() {
try {
customers = await api.orderingAdmin.customers();
} catch {}
}
async function createCustomer() {
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
try {
await api.orderingAdmin.createCustomer(newCustomer);
toast.success('Customer created.');
newCustomer = { name: '', client_code: '' };
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
}
}
async function openCustomer(c: OrderingCustomer) {
selectedCustomer = c;
discountInput = c.discount_percent;
try {
[custUsers, custPricing, custVisibility] = await Promise.all([
api.orderingAdmin.customerUsers(c.id),
api.orderingAdmin.pricing(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 saveDiscount() {
if (!selectedCustomer) return;
try {
custPricing = await api.orderingAdmin.setAssignment(selectedCustomer.id, { price_list_id: custPricing?.price_list_id ?? null, discount_percent: Number(discountInput) });
toast.success('Discount saved.');
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save discount.');
}
}
async function addProductPrice() {
if (!selectedCustomer || !newPrice.product_id) return toast.error('Choose a product.');
try {
custPricing = await api.orderingAdmin.setProductPrice(selectedCustomer.id, {
product_id: Number(newPrice.product_id),
unit_price: newPrice.rule_type === 'quote' || newPrice.unit_price === '' ? null : Number(newPrice.unit_price),
rule_type: newPrice.rule_type
});
toast.success('Customer price saved.');
newPrice = { product_id: '', unit_price: '', rule_type: 'fixed' };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save price.');
}
}
async function removeProductPrice(productId: number) {
if (!selectedCustomer) return;
try {
await api.orderingAdmin.deleteProductPrice(selectedCustomer.id, productId);
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not remove price.');
}
}
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.');
}
}
function productName(id: number) {
return products.find((p) => p.id === id)?.name ?? `#${id}`;
}
// --- Settings -------------------------------------------------------------
let settings = $state<OrderingNotificationSettings | null>(null);
async function loadSettings() {
try {
settings = await api.orderingAdmin.notificationSettings();
xero = await api.orderingAdmin.xeroStatus();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load settings.');
}
}
async function saveSettings() {
if (!settings) return;
try {
settings = await api.orderingAdmin.updateNotificationSettings(settings);
toast.success('Settings saved.');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save settings.');
}
}
$effect(() => {
if (tab === 'settings' && !settings) loadSettings();
});
</script> </script>
<div class="admin-ordering"> <svelte:window onkeydown={handleWindowKeydown} />
<header>
<p class="eyebrow">Ordering</p>
<h1>Order management</h1>
</header>
<nav class="tabs"> <div class="console-split">
<button class:active={tab === 'orders'} onclick={() => (tab = 'orders')}>Orders</button> <!-- ── Left: order queue ─────────────────────────────────────────────────── -->
<button class:active={tab === 'products'} onclick={() => (tab = 'products')}>Products</button> <section class="surface-card">
<button class:active={tab === 'customers'} onclick={() => (tab = 'customers')}>Customers & Pricing</button> <div class="card-head">
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings & Xero</button> <h2>Orders <span class="count">{filteredOrders.length}</span></h2>
</nav> </div>
{#if tab === 'orders'} <input class="list-search" type="search" placeholder="Search order or customer" bind:value={listQuery} />
<div class="split">
<section class="surface-card">
<h2>Order queue</h2>
{#if !orders.length}
<p class="empty">No submitted orders.</p>
{:else}
<table>
<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">{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} {#if filteredOrders.length}
<section class="surface-card detail"> <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 filteredOrders as o (o.id)}
{#each selectedOrder.lines as l (l.id)} <tr
<tr> class:selected={selectedOrder?.id === o.id}
<td>{l.product_name}</td> tabindex="0"
<td>{l.quantity}</td> role="button"
<td>{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td> aria-pressed={selectedOrder?.id === o.id}
<td> onclick={() => openOrder(o)}
<input class="ovr" type="number" step="0.01" placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'} onkeydown={(e) => {
onchange={(e) => overrideLine(l.id, e.currentTarget.value)} /> if (e.key === 'Enter' || e.key === ' ') {
</td> e.preventDefault();
<td>{money(l.line_total)}</td> openOrder(o);
</tr> }
{/each} }}
</tbody> >
</table> <td>
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div> <div class="id-cell">
<span class="id-name">{o.order_number ?? `#${o.id}`}</span>
<div class="actions"> <span class="id-sub">{o.customer_name ?? 'Unknown customer'}</span>
<select bind:value={statusChoice}> </div>
<option value="">Change status…</option> </td>
{#each STATUSES as s}<option value={s}>{label(s)}</option>{/each} <td><span class="pill {statusTone(o.status)}">{label(o.status)}</span></td>
</select> <td class="amt">{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button> </tr>
<button class="secondary" onclick={sendToXero}>Send to Xero</button> {/each}
<button class="secondary" onclick={reopenOrder}>Reopen</button> </tbody>
</div> </table>
{:else if orders.length}
{#if selectedOrder.status_history?.length} <p class="empty">No orders match “{listQuery}”.</p>
<details class="history"> {:else}
<summary>Status history ({selectedOrder.status_history.length})</summary> <p class="empty">No submitted orders yet.</p>
<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}
</section>
{/if}
</div>
{/if} {/if}
</section>
{#if tab === 'products'} <!-- ── Right: order detail ───────────────────────────────────────────────── -->
<section class="surface-card"> {#if selectedOrder}
<h2>New product</h2> <section class="surface-card detail workspace">
<div class="form-grid"> <div class="workspace-head">
<label>Name<input bind:value={newProduct.name} /></label> <div class="detail-head head-row">
<label>SKU<input bind:value={newProduct.sku} /></label> <div class="detail-title">
<label>Category <p class="eyebrow">{selectedOrder.customer_name ?? 'Order'}</p>
<select bind:value={newProduct.category}>{#each CATEGORIES as c}<option value={c}>{label(c)}</option>{/each}</select> <h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
</label> </div>
<label>Unit of measure<input bind:value={newProduct.unit_of_measure} /></label> <div class="detail-head-actions">
<label>Min order qty<input type="number" bind:value={newProduct.min_order_quantity} /></label> <span class="pill {statusTone(selectedOrder.status)}">{label(selectedOrder.status)}</span>
<label>Base price (ex GST)<input type="number" step="0.01" bind:value={newProduct.base_price} /></label> <button
<label class="check"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label> class="icon-btn"
<button class="primary" onclick={createProduct}>Create product</button> 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>
</section>
<section class="surface-card"> <div class="actions order-toolbar">
<h2>Catalogue ({products.length})</h2> <select bind:value={statusChoice} aria-label="Change status">
<table> <option value="">Change status…</option>
<thead><tr><th>Name</th><th>SKU</th><th>Category</th><th>Base price</th><th>Active</th><th></th></tr></thead> {#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 class="workspace-body">
<div class="meta">
<div class="meta-item"><span>PO number</span><strong>{selectedOrder.purchase_order_number ?? '—'}</strong></div>
<div class="meta-item"><span>Fulfilment</span><strong>{label(selectedOrder.fulfilment_method)}</strong></div>
<div class="meta-item"><span>Xero</span><strong>{selectedOrder.xero_status ?? 'Not sent'}</strong></div>
</div>
<table class="lines">
<thead>
<tr><th>Product</th><th class="amt">Qty</th><th class="amt">Unit</th><th>Override</th><th class="amt">Total</th></tr>
</thead>
<tbody> <tbody>
{#each products as p (p.id)} {#each selectedOrder.lines as l (l.id)}
<tr> <tr>
<td>{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</td> <td>{l.product_name}</td>
<td>{p.sku}</td> <td class="amt">{l.quantity}</td>
<td>{label(p.category)}</td> <td class="amt">{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
<td><input class="inline" type="number" step="0.01" value={p.base_price ?? ''} onchange={(e) => saveProductPrice(p, e.currentTarget.value)} /></td> <td>
<td>{p.active ? 'Yes' : 'No'}</td> <input
<td><button class="link" onclick={() => toggleProductActive(p)}>{p.active ? 'Disable' : 'Enable'}</button></td> 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> </tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
</section> <div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
{/if}
{#if tab === 'customers'} {#if selectedOrder.status_history?.length}
<div class="split"> <details class="history">
<section class="surface-card"> <summary>Status history ({selectedOrder.status_history.length})</summary>
<h2>New customer</h2> <ul>
<div class="form-row"> {#each selectedOrder.status_history as h}
<input placeholder="Company name" bind:value={newCustomer.name} />
<input placeholder="Code (e.g. ACME)" bind:value={newCustomer.client_code} />
<button class="primary" onclick={createCustomer}>Create</button>
</div>
<h2 class="mt">Customers ({customers.length})</h2>
<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">{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}
<section class="surface-card detail">
<h2>{selectedCustomer.name}</h2>
<h3>Users</h3>
<ul class="mini">
{#each custUsers as u (u.id)}
<li>{u.full_name} · {u.email} · {u.role} · {u.status}
<button class="link" onclick={() => toggleUserStatus(u)}>{u.status === 'suspended' ? 'Reactivate' : 'Suspend'}</button>
</li>
{/each}
</ul>
<div class="form-row">
<input placeholder="Full name" bind:value={newUser.full_name} />
<input placeholder="Email" bind:value={newUser.email} />
<select bind:value={newUser.role}>
<option value="owner">Owner</option><option value="buyer">Buyer</option>
<option value="accounts">Accounts</option><option value="viewer">Viewer</option>
</select>
<button class="secondary" onclick={addUser}>Invite</button>
</div>
<h3 class="mt">Pricing</h3>
<div class="form-row">
<label class="inline-label">Default discount %
<input type="number" step="0.5" bind:value={discountInput} />
</label>
<button class="secondary" onclick={saveDiscount}>Save discount</button>
</div>
{#if custPricing?.product_prices.length}
<ul class="mini">
{#each custPricing.product_prices as pp (pp.id)}
<li>{productName(pp.product_id)} · {pp.rule_type} · {pp.unit_price != null ? money(pp.unit_price) : 'quote'}
<button class="link" onclick={() => removeProductPrice(pp.product_id)}>Remove</button>
</li>
{/each}
</ul>
{/if}
<div class="form-row">
<select bind:value={newPrice.product_id}>
<option value="">Product…</option>
{#each products as p}<option value={p.id}>{p.name}</option>{/each}
</select>
<select bind:value={newPrice.rule_type}>
<option value="fixed">Fixed</option><option value="contract">Contract</option><option value="quote">Quote</option>
</select>
<input type="number" step="0.01" placeholder="Unit price" bind:value={newPrice.unit_price} disabled={newPrice.rule_type === 'quote'} />
<button class="secondary" onclick={addProductPrice}>Set price</button>
</div>
<h3 class="mt">Product visibility</h3>
<ul class="mini visibility">
{#each custVisibility as row (row.product_id)}
<li> <li>
<label class="check"><input type="checkbox" checked={row.visible} onchange={() => toggleVisibility(row)} /> {row.name}</label> {label(h.from_status ?? 'new')}{label(h.to_status)} ·
{h.actor_name ?? h.actor_type} ·
{new Date(h.created_at).toLocaleString('en-AU')}
</li> </li>
{/each} {/each}
</ul> </ul>
</section> </details>
{/if} {/if}
</div> </div>
{/if} </section>
{:else}
{#if tab === 'settings'} <section class="surface-card detail empty-detail">
<div class="split"> <span class="empty-icon" aria-hidden="true"><ClipboardList size={26} strokeWidth={1.7} /></span>
<section class="surface-card"> <h2>Select an order</h2>
<h2>Notification settings</h2> <p class="muted">Choose an order from the queue to review its lines, adjust pricing, and move it through fulfilment.</p>
{#if settings} </section>
<div class="form-grid"> {/if}
<label class="full">Internal recipients (comma separated)<input bind:value={settings.internal_recipients} /></label>
<label class="full">From email<input bind:value={settings.from_email} /></label>
<label class="check"><input type="checkbox" bind:checked={settings.send_customer_confirmation} /> Send customer confirmation</label>
<label class="check"><input type="checkbox" bind:checked={settings.require_po_number} /> Require PO number on submit</label>
<button class="primary" onclick={saveSettings}>Save settings</button>
</div>
{:else}
<p class="empty">Loading…</p>
{/if}
</section>
<section class="surface-card">
<h2>Xero integration</h2>
{#if xero}
<p class="muted">Mode: <strong>{xero.connection.mode}</strong> · {xero.connection.configured ? 'Configured' : 'Not configured (stub mode)'}</p>
{#if xero.connection.missing_env.length}
<p class="muted">Missing env: {xero.connection.missing_env.join(', ')}</p>
{/if}
<h3>Recent syncs</h3>
{#if !xero.recent_syncs.length}
<p class="empty">No Xero submissions yet.</p>
{:else}
<ul class="mini">
{#each xero.recent_syncs as s (s.id)}
<li>Order {s.order_id} · {s.status} · {s.xero_invoice_id ?? '—'} · {new Date(s.created_at).toLocaleString('en-AU')}</li>
{/each}
</ul>
{/if}
{:else}
<p class="empty">Loading…</p>
{/if}
</section>
</div>
{/if}
</div> </div>
<style> <style>
.admin-ordering { display: grid; gap: 1rem; } /* Right-align numeric columns for clean scanning. */
h1 { margin: 0.15rem 0; font-size: 1.4rem; } .amt {
h2 { margin: 0 0 0.7rem; font-size: 1.05rem; } text-align: right;
h3 { margin: 0 0 0.4rem; font-size: 0.92rem; } font-variant-numeric: tabular-nums;
.mt { margin-top: 1rem; } white-space: nowrap;
.eyebrow { color: #6e8576; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } }
.muted { color: #64776b; font-size: 0.84rem; margin: 0 0 0.6rem; }
.surface-card { border: 1px solid rgba(34,54,45,0.12); border-radius: 1rem; background: rgba(255,255,255,0.92); padding: 1.1rem; } .clickable tbody tr:focus-visible {
.tabs { display: flex; gap: 0.4rem; flex-wrap: wrap; } outline: 2px solid var(--color-brand);
.tabs button { padding: 0.45rem 0.9rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.7rem; background: transparent; cursor: pointer; font-weight: 600; font-size: 0.86rem; } outline-offset: -2px;
.tabs button.active { background: var(--color-brand, #2f6f4f); color: #fff; border-color: transparent; } }
.split { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } /* The header already carries the divider, so the title row drops its own. */
th, td { text-align: left; padding: 0.45rem 0.55rem; border-bottom: 1px solid rgba(34,54,45,0.08); } .head-row {
th { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: #7c8c82; } border-bottom: none;
tbody tr { cursor: pointer; } padding-bottom: 0;
tbody tr.selected { background: rgba(47,111,79,0.08); } margin-bottom: 0.85rem;
.pill { padding: 0.16rem 0.5rem; border-radius: 999px; font-size: 0.72rem; font-weight: 600; background: rgba(34,54,45,0.08); } }
.tag { margin-left: 0.4rem; font-size: 0.64rem; padding: 0.05rem 0.35rem; border-radius: 999px; background: #fdf0d5; color: #8a5a00; } .order-toolbar {
.empty { color: #7c8c82; font-size: 0.85rem; } margin-bottom: 0.9rem;
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; align-items: end; } }
.form-grid .full { grid-column: 1 / -1; }
.form-grid label, .form-row label { display: grid; gap: 0.2rem; font-size: 0.76rem; color: #64776b; }
.form-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.6rem; }
.form-row input, .form-row select, .form-grid input, .form-grid select { padding: 0.45rem 0.55rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; font: inherit; }
.check { display: flex; flex-direction: row; align-items: center; gap: 0.4rem; }
.inline-label { flex-direction: row; align-items: center; gap: 0.45rem; }
.inline { width: 6rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
.ovr { width: 5rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
.primary, .secondary { border-radius: 0.6rem; padding: 0.5rem 0.9rem; font-weight: 600; cursor: pointer; border: none; }
.primary { background: var(--color-brand, #2f6f4f); color: #fff; }
.secondary { background: rgba(34,54,45,0.08); color: #22362d; }
.link { background: none; border: none; color: var(--color-brand, #2f6f4f); cursor: pointer; font-size: 0.8rem; padding: 0; }
.lines td { font-size: 0.82rem; }
.detail-total { display: flex; justify-content: space-between; padding: 0.5rem 0; border-top: 1px solid rgba(34,54,45,0.12); margin: 0.4rem 0; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.actions select { padding: 0.45rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; }
.history { margin-top: 0.8rem; font-size: 0.8rem; }
.mini { list-style: none; margin: 0.3rem 0 0.6rem; padding: 0; display: grid; gap: 0.3rem; font-size: 0.82rem; }
.mini li { display: flex; gap: 0.5rem; align-items: center; justify-content: space-between; }
.visibility li { justify-content: flex-start; }
@media (max-width: 1000px) { .split, .form-grid { grid-template-columns: 1fr; } }
</style> </style>
+7 -20
View File
@@ -1,30 +1,17 @@
import { redirect } from '@sveltejs/kit'; import { hasStoredClientSession } from '$lib/session';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access'; import type { Order } from '$lib/types';
const EMPTY = { orders: [], products: [], customers: [], xero: null } as const;
// Orders queue. Access is already enforced by the family +layout.ts guard.
export async function load({ fetch }) { export async function load({ fetch }) {
if (!hasStoredClientSession()) { if (!hasStoredClientSession()) {
return { ...EMPTY }; return { orders: [] as Order[] };
}
const session = getStoredClientSession();
if (!canManageOrdering(session)) {
// Customers (or anyone without manage rights) don't belong here.
throw redirect(307, getWorkspaceHomeHref(session));
} }
try { try {
const [orders, products, customers, xero] = await Promise.all([ const orders = await api.orderingAdmin.orders(undefined, fetch);
api.orderingAdmin.orders(undefined, fetch), return { orders };
api.orderingAdmin.products(fetch),
api.orderingAdmin.customers(fetch),
api.orderingAdmin.xeroStatus(fetch)
]);
return { orders, products, customers, xero };
} catch { } catch {
return { ...EMPTY }; return { orders: [] as Order[] };
} }
} }
@@ -0,0 +1,170 @@
<script lang="ts">
import { tick } from 'svelte';
import { Building2, Plus } from 'lucide-svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import { statusTone } from '$lib/ordering/format';
import CustomerWorkspace from '$lib/components/ordering/CustomerWorkspace.svelte';
import type { OrderingCustomer } from '$lib/types';
let { data } = $props();
let customers = $state<OrderingCustomer[]>([]);
$effect(() => {
customers = data.customers ?? [];
});
// ── Customer list: search + inline create ──────────────────────────────────
let listQuery = $state('');
const filteredCustomers = $derived.by(() => {
const q = listQuery.trim().toLowerCase();
if (!q) return customers;
return customers.filter(
(c) => c.name.toLowerCase().includes(q) || c.client_code.toLowerCase().includes(q)
);
});
let newCustomer = $state({ name: '', client_code: '' });
let showNewCustomer = $state(false);
let newCustomerNameInput: HTMLInputElement | null = $state(null);
function toggleNewCustomer() {
showNewCustomer = !showNewCustomer;
if (showNewCustomer) newCustomer = { name: '', client_code: '' };
}
$effect(() => {
if (showNewCustomer) tick().then(() => newCustomerNameInput?.focus());
});
let selectedCustomer = $state<OrderingCustomer | null>(null);
function openCustomer(c: OrderingCustomer) {
selectedCustomer = c;
}
function closeDetail() {
selectedCustomer = null;
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return;
if (showNewCustomer) showNewCustomer = false;
else if (selectedCustomer) closeDetail();
}
async function refreshCustomers(updated?: OrderingCustomer) {
if (updated && selectedCustomer?.id === updated.id) selectedCustomer = updated;
try {
customers = await api.orderingAdmin.customers();
} catch {}
}
async function createCustomer() {
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
try {
await api.orderingAdmin.createCustomer(newCustomer);
toast.success('Customer created.');
newCustomer = { name: '', client_code: '' };
showNewCustomer = false;
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
}
}
</script>
<svelte:window onkeydown={handleWindowKeydown} />
<div class="console-split">
<!-- ── Left: customer roster ─────────────────────────────────────────────── -->
<section class="surface-card">
<div class="card-head">
<h2>Customers <span class="count">{filteredCustomers.length}</span></h2>
<button class="primary" onclick={toggleNewCustomer}>
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
{showNewCustomer ? 'Cancel' : 'New customer'}
</button>
</div>
{#if showNewCustomer}
<div class="create-panel">
<div class="form-grid">
<label class="full">Company name
<input bind:this={newCustomerNameInput} bind:value={newCustomer.name} />
</label>
<label class="full">Client code
<input placeholder="e.g. ACME" bind:value={newCustomer.client_code} />
</label>
</div>
<div class="actions">
<button class="secondary" onclick={toggleNewCustomer}>Cancel</button>
<button class="primary" onclick={createCustomer}>Create customer</button>
</div>
</div>
{/if}
<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>
<!-- ── Right: customer workspace ─────────────────────────────────────────── -->
{#if selectedCustomer}
<CustomerWorkspace customer={selectedCustomer} onChanged={refreshCustomers} onClose={closeDetail} />
{:else}
<section class="surface-card detail empty-detail">
<span class="empty-icon" aria-hidden="true"><Building2 size={26} strokeWidth={1.7} /></span>
<h2>Select a customer</h2>
<p class="muted">
Pick a company to open its workspace: details, people, catalogue access, orders, mixes, and history in one place.
</p>
</section>
{/if}
</div>
<style>
.amt { text-align: right; font-variant-numeric: tabular-nums; }
.primary { gap: 0.4rem; }
.primary :global(svg) { display: block; }
.clickable tbody tr:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: -2px;
}
</style>
@@ -0,0 +1,17 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import type { OrderingCustomer } from '$lib/types';
// Customer accounts. Access enforced by the family +layout.ts guard.
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { customers: [] as OrderingCustomer[] };
}
try {
const customers = await api.orderingAdmin.customers(fetch);
return { customers };
} catch {
return { customers: [] as OrderingCustomer[] };
}
}
@@ -0,0 +1,60 @@
<script lang="ts">
import { Plug } from 'lucide-svelte';
// Connected systems. Each integration has its own page nested under this one
// (and a matching submenu entry in the rail).
const integrations = [
{
href: '/ordering/manage/integrations/xero',
name: 'Xero',
description: 'Send confirmed order invoices to Xero and map customers to their Xero contact.'
}
];
</script>
<section class="surface-card">
<h2>Integrations</h2>
<p class="muted">Connect the ordering portal to the systems you already use.</p>
<ul class="integration-list">
{#each integrations as it (it.href)}
<li>
<a class="integration" href={it.href}>
<span class="ico"><Plug size={18} strokeWidth={1.75} /></span>
<span class="body">
<span class="name">{it.name}</span>
<span class="desc">{it.description}</span>
</span>
</a>
</li>
{/each}
</ul>
</section>
<style>
.integration-list { list-style: none; margin: 0.5rem 0 0; padding: 0; display: grid; gap: 0.6rem; }
.integration {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.85rem 0.95rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--panel-soft);
transition: border-color 140ms ease, background-color 140ms ease;
}
.integration:hover { border-color: var(--color-brand); background: var(--color-surface-hover); }
.ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
border-radius: var(--radius-control);
background: var(--color-bg-surface);
color: var(--color-brand);
flex-shrink: 0;
}
.body { display: grid; gap: 0.15rem; min-width: 0; }
.name { font-weight: 700; font-size: 0.95rem; color: var(--color-text-primary); }
.desc { font-size: 0.82rem; color: var(--color-text-muted); }
</style>
@@ -0,0 +1,5 @@
// Integrations landing. Individual integrations (Xero) load their own data on
// their nested routes. Access enforced by the family +layout.ts guard.
export function load() {
return {};
}
@@ -0,0 +1,180 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import type { XeroContact, XeroContactLinkRow, XeroStatus } from '$lib/types';
let { data } = $props();
let xero = $state<XeroStatus | null>(null);
let contacts = $state<XeroContact[]>([]);
let contactsStubbed = $state(false);
let links = $state<XeroContactLinkRow[]>([]);
// The contact selected in each customer's dropdown, keyed by customer id. Seeded
// from the saved link or the server's suggested match so the operator usually
// just confirms.
let choice = $state<Record<number, string>>({});
// Hydrate local state from the loader. Read only from `data` here — referencing
// a writable state we also assign (e.g. `links`) inside the same effect would
// retrigger it forever (svelte effect_update_depth_exceeded).
$effect(() => {
const nextLinks = data.links ?? [];
xero = data.xero ?? null;
contacts = data.contacts ?? [];
contactsStubbed = data.contactsStubbed ?? false;
links = nextLinks;
choice = Object.fromEntries(
nextLinks.map((l) => [l.customer_id, l.xero_contact_id ?? l.suggested_contact_id ?? ''])
);
});
const linkedCount = $derived(links.filter((l) => l.linked).length);
async function refresh() {
try {
const [status, list, rows] = await Promise.all([
api.orderingAdmin.xeroStatus(),
api.orderingAdmin.xeroContacts(),
api.orderingAdmin.xeroContactLinks()
]);
xero = status;
contacts = list.contacts;
contactsStubbed = list.stubbed;
links = rows;
choice = Object.fromEntries(
rows.map((l) => [l.customer_id, l.xero_contact_id ?? l.suggested_contact_id ?? ''])
);
} catch {}
}
async function saveLink(row: XeroContactLinkRow) {
const contactId = choice[row.customer_id];
if (!contactId) return toast.error('Choose a Xero contact first.');
const contact = contacts.find((c) => c.contact_id === contactId);
try {
await api.orderingAdmin.linkCustomerToXero(row.customer_id, {
xero_contact_id: contactId,
xero_contact_name: contact?.name ?? null,
xero_contact_email: contact?.email ?? null
});
toast.success(`${row.customer_name} linked to ${contact?.name ?? 'Xero contact'}.`);
await refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not link customer.');
}
}
async function removeLink(row: XeroContactLinkRow) {
try {
await api.orderingAdmin.unlinkCustomerFromXero(row.customer_id);
toast.success(`${row.customer_name} unlinked.`);
await refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not unlink customer.');
}
}
function isDirty(row: XeroContactLinkRow): boolean {
const sel = choice[row.customer_id] ?? '';
return !!sel && sel !== (row.xero_contact_id ?? '');
}
</script>
<section class="surface-card">
<h2>Xero connection</h2>
{#if xero}
<p class="muted">Mode: <strong>{xero.connection.mode}</strong> · {xero.connection.configured ? 'Configured' : 'Not configured (stub mode)'}</p>
{#if xero.connection.missing_env.length}
<p class="muted">Missing env: {xero.connection.missing_env.join(', ')}</p>
{/if}
<p class="muted">
Customers linked to a Xero contact:
<strong>{xero.contact_links.linked}</strong> of {xero.contact_links.total}
{#if xero.contact_links.unlinked}· <span class="warn-text">{xero.contact_links.unlinked} unlinked</span>{/if}
</p>
{:else}
<p class="empty">Could not load integration status.</p>
{/if}
</section>
<section class="surface-card">
<div class="card-head">
<h2>Customer ↔ Xero contact mapping</h2>
<span class="count">{linkedCount}/{links.length} linked</span>
</div>
<p class="muted">
Link each customer in our database to its contact in Xero. Once linked, that
customer's order invoices are raised against the matched Xero contact instead
of being matched by code.
{#if contactsStubbed}<br />Showing <strong>sample</strong> Xero contacts — live contacts appear once Xero credentials are configured.{/if}
</p>
{#if !links.length}
<p class="empty">No customers yet.</p>
{:else}
<table>
<thead>
<tr><th>Customer</th><th>Code</th><th>Xero contact</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{#each links as row (row.customer_id)}
<tr>
<td>{row.customer_name}</td>
<td>{row.client_code}</td>
<td>
<select bind:value={choice[row.customer_id]}>
<option value="">— Not linked —</option>
{#each contacts as c (c.contact_id)}
<option value={c.contact_id}>{c.name}{c.email ? ` (${c.email})` : ''}</option>
{/each}
</select>
{#if !row.linked && row.suggested_contact_id}
<span class="hint">suggested match</span>
{/if}
</td>
<td>
{#if row.linked}
<span class="pill pos">Linked</span>
{:else}
<span class="pill warn">Unlinked</span>
{/if}
</td>
<td class="row-actions">
<button
class="primary sm"
onclick={() => saveLink(row)}
disabled={!choice[row.customer_id] || (row.linked && !isDirty(row))}
>
{row.linked ? 'Update' : 'Link'}
</button>
{#if row.linked}
<button class="link" onclick={() => removeLink(row)}>Unlink</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<section class="surface-card">
<h2>Recent syncs</h2>
{#if !xero || !xero.recent_syncs.length}
<p class="empty">No Xero submissions yet.</p>
{:else}
<ul class="mini">
{#each xero.recent_syncs as s (s.id)}
<li>Order {s.order_id} · {s.status} · {s.xero_invoice_id ?? '—'} · {new Date(s.created_at).toLocaleString('en-AU')}</li>
{/each}
</ul>
{/if}
</section>
<style>
.count { font-size: 0.78rem; font-weight: 600; color: var(--color-text-muted); }
.row-actions { display: flex; align-items: center; gap: 0.6rem; white-space: nowrap; }
.primary.sm { min-height: 1.9rem; padding: 0.3rem 0.7rem; font-size: 0.8rem; }
.hint { display: block; margin-top: 0.2rem; font-size: 0.68rem; color: var(--color-info); }
.warn-text { color: var(--color-warning-text); font-weight: 600; }
</style>
@@ -0,0 +1,37 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import type { XeroContact, XeroContactLinkRow, XeroStatus } from '$lib/types';
// Xero integration status + customer→contact mapping. Access enforced by the
// family +layout.ts guard.
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
xero: null as XeroStatus | null,
contacts: [] as XeroContact[],
contactsStubbed: false,
links: [] as XeroContactLinkRow[]
};
}
try {
const [xero, contactList, links] = await Promise.all([
api.orderingAdmin.xeroStatus(fetch),
api.orderingAdmin.xeroContacts(fetch),
api.orderingAdmin.xeroContactLinks(fetch)
]);
return {
xero,
contacts: contactList.contacts,
contactsStubbed: contactList.stubbed,
links
};
} catch {
return {
xero: null as XeroStatus | null,
contacts: [] as XeroContact[],
contactsStubbed: false,
links: [] as XeroContactLinkRow[]
};
}
}
@@ -0,0 +1,116 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import { money } from '$lib/ordering/format';
import type { CatalogueProduct, CustomerPricing, OrderingCustomer } from '$lib/types';
let { data } = $props();
let customers = $state<OrderingCustomer[]>([]);
let products = $state<CatalogueProduct[]>([]);
$effect(() => {
customers = data.customers ?? [];
products = data.products ?? [];
});
let selectedId = $state('');
let selectedCustomer = $derived(customers.find((c) => String(c.id) === selectedId) ?? null);
let custPricing = $state<CustomerPricing | null>(null);
let discountInput = $state(0);
let newPrice = $state<Record<string, any>>({ product_id: '', unit_price: '', rule_type: 'fixed' });
async function loadPricing() {
custPricing = null;
if (!selectedCustomer) return;
discountInput = selectedCustomer.discount_percent;
try {
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load pricing.');
}
}
async function saveDiscount() {
if (!selectedCustomer) return;
try {
custPricing = await api.orderingAdmin.setAssignment(selectedCustomer.id, { price_list_id: custPricing?.price_list_id ?? null, discount_percent: Number(discountInput) });
toast.success('Discount saved.');
customers = await api.orderingAdmin.customers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save discount.');
}
}
async function addProductPrice() {
if (!selectedCustomer || !newPrice.product_id) return toast.error('Choose a product.');
try {
custPricing = await api.orderingAdmin.setProductPrice(selectedCustomer.id, {
product_id: Number(newPrice.product_id),
unit_price: newPrice.rule_type === 'quote' || newPrice.unit_price === '' ? null : Number(newPrice.unit_price),
rule_type: newPrice.rule_type
});
toast.success('Customer price saved.');
newPrice = { product_id: '', unit_price: '', rule_type: 'fixed' };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save price.');
}
}
async function removeProductPrice(productId: number) {
if (!selectedCustomer) return;
try {
await api.orderingAdmin.deleteProductPrice(selectedCustomer.id, productId);
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not remove price.');
}
}
function productName(id: number) {
return products.find((p) => p.id === id)?.name ?? `#${id}`;
}
</script>
<section class="surface-card">
<h2>Customer pricing</h2>
<div class="form-row">
<label class="inline-label">Customer
<select bind:value={selectedId} onchange={loadPricing}>
<option value="">Select a customer…</option>
{#each customers as c (c.id)}<option value={String(c.id)}>{c.name} ({c.client_code})</option>{/each}
</select>
</label>
</div>
{#if !selectedCustomer}
<p class="empty">Choose a customer to view and edit their discount and per-product prices.</p>
{:else}
<h3 class="mt">Default discount</h3>
<div class="form-row">
<label class="inline-label">Default discount %
<input type="number" step="0.5" bind:value={discountInput} />
</label>
<button class="secondary" onclick={saveDiscount}>Save discount</button>
</div>
<h3 class="mt">Per-product prices</h3>
{#if custPricing?.product_prices.length}
<ul class="mini">
{#each custPricing.product_prices as pp (pp.id)}
<li>{productName(pp.product_id)} · {pp.rule_type} · {pp.unit_price != null ? money(pp.unit_price) : 'quote'}
<button class="link" onclick={() => removeProductPrice(pp.product_id)}>Remove</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">No product-specific prices. The default discount applies to base prices.</p>
{/if}
<div class="form-row">
<select bind:value={newPrice.product_id}>
<option value="">Product…</option>
{#each products as p}<option value={p.id}>{p.name}</option>{/each}
</select>
<select bind:value={newPrice.rule_type}>
<option value="fixed">Fixed</option><option value="contract">Contract</option><option value="quote">Quote</option>
</select>
<input type="number" step="0.01" placeholder="Unit price" bind:value={newPrice.unit_price} disabled={newPrice.rule_type === 'quote'} />
<button class="secondary" onclick={addProductPrice}>Set price</button>
</div>
{/if}
</section>
@@ -0,0 +1,22 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import type { CatalogueProduct, OrderingCustomer } from '$lib/types';
// Pricing needs the customer list (to choose whose pricing to edit) and the
// product catalogue (for the price-rule product picker). Per-customer pricing
// itself is loaded client-side once a customer is selected.
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { customers: [] as OrderingCustomer[], products: [] as CatalogueProduct[] };
}
try {
const [customers, products] = await Promise.all([
api.orderingAdmin.customers(fetch),
api.orderingAdmin.products(fetch)
]);
return { customers, products };
} catch {
return { customers: [] as OrderingCustomer[], products: [] as CatalogueProduct[] };
}
}
@@ -0,0 +1,210 @@
<script lang="ts">
import { tick } from 'svelte';
import { Plus } from 'lucide-svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import { tooltip } from '$lib/actions/tooltip';
import { label, PRODUCT_CATEGORIES } from '$lib/ordering/format';
import type { CatalogueProduct } from '$lib/types';
let { data } = $props();
let products = $state<CatalogueProduct[]>([]);
$effect(() => {
products = data.products ?? [];
});
let listQuery = $state('');
const filteredProducts = $derived.by(() => {
const q = listQuery.trim().toLowerCase();
if (!q) return products;
return products.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.sku.toLowerCase().includes(q) ||
label(p.category).toLowerCase().includes(q)
);
});
const blankProduct = () => ({
name: '',
sku: '',
category: 'grains',
unit_of_measure: '20kg bag',
min_order_quantity: 1,
base_price: null as number | null,
requires_quote: false,
active: true
});
let newProduct = $state<Record<string, any>>(blankProduct());
let showNewProduct = $state(false);
let newProductNameInput: HTMLInputElement | null = $state(null);
function toggleNewProduct() {
showNewProduct = !showNewProduct;
if (showNewProduct) newProduct = blankProduct();
}
$effect(() => {
if (showNewProduct) tick().then(() => newProductNameInput?.focus());
});
async function refreshProducts() {
try {
products = await api.orderingAdmin.products();
} catch {}
}
async function createProduct() {
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
try {
await api.orderingAdmin.createProduct({
...newProduct,
base_price:
newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price)
});
toast.success('Product created.');
newProduct = blankProduct();
showNewProduct = false;
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create product.');
}
}
async function toggleProductActive(p: CatalogueProduct) {
try {
await api.orderingAdmin.updateProduct(p.id, { active: !p.active });
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function saveProductPrice(p: CatalogueProduct, value: string) {
try {
await api.orderingAdmin.updateProduct(p.id, { base_price: value === '' ? null : Number(value) });
toast.success('Base price updated.');
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
</script>
<section class="surface-card">
<div class="card-head">
<h2>Catalogue <span class="count">{filteredProducts.length}</span></h2>
<button class="primary" onclick={toggleNewProduct}>
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
{showNewProduct ? 'Cancel' : 'New product'}
</button>
</div>
{#if showNewProduct}
<div class="create-panel">
<div class="form-grid">
<label>Name<input bind:this={newProductNameInput} bind:value={newProduct.name} /></label>
<label>SKU<input bind:value={newProduct.sku} /></label>
<label>Category
<select bind:value={newProduct.category}>
{#each PRODUCT_CATEGORIES as c}<option value={c}>{label(c)}</option>{/each}
</select>
</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>Base price (ex GST)<input type="number" step="0.01" bind:value={newProduct.base_price} /></label>
<label class="check full"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label>
</div>
<div class="actions">
<button class="secondary" onclick={toggleNewProduct}>Cancel</button>
<button class="primary" onclick={createProduct}>Create product</button>
</div>
</div>
{/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>
@@ -0,0 +1,17 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import type { CatalogueProduct } from '$lib/types';
// Catalogue products. Access enforced by the family +layout.ts guard.
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { products: [] as CatalogueProduct[] };
}
try {
const products = await api.orderingAdmin.products(fetch);
return { products };
} catch {
return { products: [] as CatalogueProduct[] };
}
}
@@ -0,0 +1,37 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import type { OrderingNotificationSettings } from '$lib/types';
let { data } = $props();
let settings = $state<OrderingNotificationSettings | null>(null);
$effect(() => {
settings = data.settings ?? null;
});
async function saveSettings() {
if (!settings) return;
try {
settings = await api.orderingAdmin.updateNotificationSettings(settings);
toast.success('Settings saved.');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save settings.');
}
}
</script>
<section class="surface-card">
<h2>Notification settings</h2>
{#if settings}
<div class="form-grid">
<label class="full">Internal recipients (comma separated)<input bind:value={settings.internal_recipients} /></label>
<label class="full">From email<input bind:value={settings.from_email} /></label>
<label class="check"><input type="checkbox" bind:checked={settings.send_customer_confirmation} /> Send customer confirmation</label>
<label class="check"><input type="checkbox" bind:checked={settings.require_po_number} /> Require PO number on submit</label>
<button class="primary" onclick={saveSettings}>Save settings</button>
</div>
{:else}
<p class="empty">Could not load settings.</p>
{/if}
</section>
@@ -0,0 +1,17 @@
import { hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import type { OrderingNotificationSettings } from '$lib/types';
// Notification settings. Access enforced by the family +layout.ts guard.
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { settings: null as OrderingNotificationSettings | null };
}
try {
const settings = await api.orderingAdmin.notificationSettings(fetch);
return { settings };
} catch {
return { settings: null as OrderingNotificationSettings | null };
}
}
@@ -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;
@@ -735,12 +727,34 @@
--costing-line: oklch(88% 0.014 145); --costing-line: oklch(88% 0.014 145);
--costing-line-strong: oklch(78% 0.02 145); --costing-line-strong: oklch(78% 0.02 145);
--costing-warn: oklch(58% 0.14 72); --costing-warn: oklch(58% 0.14 72);
--costing-input-bg: oklch(99% 0.004 145);
--costing-warn-soft: oklch(95% 0.055 82);
--costing-warn-box: oklch(96% 0.045 84);
--costing-warn-border: oklch(84% 0.085 78);
--costing-warn-row: oklch(92% 0.08 81);
display: grid; display: grid;
gap: 1.05rem; gap: 1.05rem;
color: var(--costing-ink); color: var(--costing-ink);
} }
.page-head, /* Dark mode: remap the costing palette onto the neutral dark theme tokens so
the worksheet themes alongside the rest of the app instead of staying a
bright light card. The warn tints lean on the global warning tokens. */
:global([data-theme='dark']) .costing-shell {
--costing-ink: var(--color-text-primary);
--costing-muted: var(--color-text-muted);
--costing-panel: var(--color-bg-surface);
--costing-soft: var(--color-bg-app);
--costing-line: var(--color-border);
--costing-line-strong: var(--color-border);
--costing-warn: var(--color-warning-text);
--costing-input-bg: var(--color-input-bg);
--costing-warn-soft: var(--color-warning-tint);
--costing-warn-box: var(--color-warning-tint);
--costing-warn-border: color-mix(in srgb, var(--color-warning) 45%, var(--color-border));
--costing-warn-row: var(--color-warning);
}
.health-strip, .health-strip,
.workspace-grid, .workspace-grid,
.section-toolbar, .section-toolbar,
@@ -753,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,
@@ -772,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;
@@ -809,7 +800,7 @@
padding: 0.38rem 0.62rem; padding: 0.38rem 0.62rem;
border-radius: 999px; border-radius: 999px;
color: var(--costing-warn); color: var(--costing-warn);
background: oklch(95% 0.055 82); background: var(--costing-warn-soft);
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 800; font-weight: 800;
} }
@@ -881,7 +872,7 @@
} }
.health-card.warning { .health-card.warning {
background: color-mix(in srgb, oklch(91% 0.08 80) 34%, var(--costing-panel)); background: color-mix(in srgb, var(--costing-warn-row) 34%, var(--costing-panel));
} }
.health-card strong { .health-card strong {
@@ -978,7 +969,7 @@
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border: 1px solid var(--costing-line-strong); border: 1px solid var(--costing-line-strong);
border-radius: 0.66rem; border-radius: 0.66rem;
background: oklch(99% 0.004 145); background: var(--costing-input-bg);
color: var(--costing-ink); color: var(--costing-ink);
} }
@@ -995,7 +986,7 @@
align-items: center; align-items: center;
border: 1px solid var(--costing-line-strong); border: 1px solid var(--costing-line-strong);
border-radius: 0.66rem; border-radius: 0.66rem;
background: oklch(99% 0.004 145); background: var(--costing-input-bg);
overflow: hidden; overflow: hidden;
} }
@@ -1085,7 +1076,7 @@
} }
tbody tr.warn td { tbody tr.warn td {
background: color-mix(in srgb, oklch(93% 0.08 83) 45%, var(--costing-panel)); background: color-mix(in srgb, var(--costing-warn-row) 45%, var(--costing-panel));
} }
tbody td { tbody td {
@@ -1166,7 +1157,7 @@
.status-pill.warning { .status-pill.warning {
color: var(--costing-warn); color: var(--costing-warn);
background: oklch(95% 0.055 82); background: var(--costing-warn-soft);
} }
.skeleton-row td { .skeleton-row td {
@@ -1257,10 +1248,10 @@
align-items: flex-start; align-items: flex-start;
padding: 0.78rem; padding: 0.78rem;
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
border: 1px solid oklch(84% 0.085 78); border: 1px solid var(--costing-warn-border);
border-radius: 0.82rem; border-radius: 0.82rem;
color: var(--costing-warn); color: var(--costing-warn);
background: oklch(96% 0.045 84); background: var(--costing-warn-box);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
} }
+13 -13
View File
@@ -617,7 +617,7 @@
} }
.eyebrow { .eyebrow {
color: #7f8e85; color: var(--color-text-muted);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -680,14 +680,14 @@
.feedback.success { .feedback.success {
color: var(--green-deep); color: var(--green-deep);
border-color: #d8ecdf; border-color: color-mix(in srgb, var(--color-success) 22%, var(--color-border));
background: #f6fcf8; background: color-mix(in srgb, var(--color-success-tint) 55%, var(--color-bg-surface));
} }
.feedback.error { .feedback.error {
color: #a03737; color: var(--color-error);
border-color: #f0d9d9; border-color: color-mix(in srgb, var(--color-error) 22%, var(--color-border));
background: #fff8f8; background: color-mix(in srgb, var(--color-error) 8%, var(--color-bg-surface));
} }
.metric-row, .metric-row,
@@ -839,7 +839,7 @@
label { label {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
color: #53645b; color: var(--color-text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
} }
@@ -860,7 +860,7 @@
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
border: none; border: none;
border-radius: 0.9rem; border-radius: 0.9rem;
color: #fff; color: var(--color-on-brand);
background: var(--color-brand); background: var(--color-brand);
box-shadow: none; box-shadow: none;
font-weight: 600; font-weight: 600;
@@ -955,13 +955,13 @@
} }
.material-icon.active { .material-icon.active {
color: #fff; color: var(--color-on-brand);
background: var(--color-brand); background: var(--color-brand);
} }
.material-icon.muted { .material-icon.muted {
color: #55685f; color: var(--color-text-secondary);
background: #e9efeb; background: var(--color-surface-hover);
} }
.status-pill { .status-pill {
@@ -981,8 +981,8 @@
} }
.status-pill.neutral { .status-pill.neutral {
color: #5a6c63; color: var(--color-text-secondary);
background: #edf2ef; background: var(--color-surface-hover);
} }
.material-grid { .material-grid {
+343 -5
View File
@@ -3,12 +3,17 @@
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte'; import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte'; import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import { clientSession } from '$lib/session'; import { clientSession } from '$lib/session';
import { canEditThroughput } from '$lib/workspace-access';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import { CircleUserRound, LockKeyhole } from 'lucide-svelte'; import type { ThroughputImportResult } from '$lib/types';
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte';
type Section = 'profile' | 'security'; type Section = 'profile' | 'security' | 'import';
let activeSection = $state<Section>('profile'); let activeSection = $state<Section>('profile');
// Only operators who can edit throughput see (and can use) the import tool.
const canImportThroughput = $derived(canEditThroughput($clientSession));
let name = $state($clientSession?.name ?? ''); let name = $state($clientSession?.name ?? '');
let email = $state($clientSession?.email ?? ''); let email = $state($clientSession?.email ?? '');
@@ -69,6 +74,90 @@
} }
} }
// ── Throughput import ─────────────────────────────────────────
let importFile = $state<File | null>(null);
let importing = $state(false);
let importResult = $state<ThroughputImportResult | null>(null);
let importError = $state('');
function onImportFileChange(event: Event) {
const input = event.currentTarget as HTMLInputElement;
importFile = input.files?.[0] ?? null;
importResult = null;
importError = '';
}
async function runImport() {
if (!importFile) {
importError = 'Choose a CSV or spreadsheet file first.';
return;
}
importing = true;
importError = '';
importResult = null;
const tid = toast.loading('Importing entries…');
try {
const result = await api.importThroughputEntries(importFile);
importResult = result;
toast.dismiss(tid);
if (result.entries_imported > 0) {
toast.success(
`Imported ${result.entries_imported} ${result.entries_imported === 1 ? 'entry' : 'entries'}`
);
} else {
toast.error('No entries were imported. Check the file and try again.');
}
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'Import failed';
importError = msg;
toast.error(msg);
} finally {
importing = false;
}
}
// Build a small sample CSV in the browser so operators have a working header
// row to copy from. The backend matches these headers case-insensitively.
function downloadTemplate() {
const headers = [
'Date',
'Product',
'Item ID',
'Quantity',
'Type',
'Bag Size',
'Packed By',
'For Order',
'Job Number',
'For Stock',
'Stock Quantity',
'Notes'
];
const example = [
'2026-06-12',
'Specialty Pigeon Breeder',
'',
'40',
'bags',
'20',
'Jane Doe',
'yes',
'JOB1234',
'no',
'',
'First run of the day'
];
const csv = `${headers.join(',')}\n${example.join(',')}\n`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'throughput-import-template.csv';
link.click();
URL.revokeObjectURL(url);
}
const initials = $derived( const initials = $derived(
($clientSession?.name ?? '') ($clientSession?.name ?? '')
.split(' ') .split(' ')
@@ -78,12 +167,13 @@
.toUpperCase() || '?' .toUpperCase() || '?'
); );
const navItems: { id: Section; label: string; icon: typeof CircleUserRound }[] = [ const navItems = $derived<{ id: Section; label: string; icon: typeof CircleUserRound }[]>([
{ id: 'profile', label: 'Profile', icon: CircleUserRound }, { id: 'profile', label: 'Profile', icon: CircleUserRound },
{ id: 'security', label: 'Security', icon: LockKeyhole }, { id: 'security', label: 'Security', icon: LockKeyhole },
]; ...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
]);
const railGroups = [{ items: navItems }]; const railGroups = $derived([{ items: navItems }]);
</script> </script>
<AppSecondaryRailLayout> <AppSecondaryRailLayout>
@@ -164,6 +254,92 @@
</div> </div>
</form> </form>
</div> </div>
{:else if activeSection === 'import' && canImportThroughput}
<div class="panel-section">
<header class="panel-header">
<h2>Import throughput entries</h2>
<p>Upload a CSV or Excel (.xlsx) file of packing runs. Each row is saved as a throughput entry.</p>
</header>
<div class="import-body">
<div class="import-help">
<h3>Required columns</h3>
<p>
Your file needs a header row with at least <strong>Date</strong>, <strong>Product</strong>
and <strong>Quantity</strong> columns. These optional columns are also recognised:
</p>
<ul>
<li><strong>Type</strong><code>bags</code> or <code>kg</code> (inferred from bag size if omitted)</li>
<li><strong>Bag Size</strong> — kg per bag (required when packing as bags)</li>
<li><strong>Item ID</strong> — matches an existing product; otherwise matched by name</li>
<li><strong>Packed By</strong>, <strong>Notes</strong></li>
<li><strong>For Order</strong>, <strong>Job Number</strong>, <strong>For Stock</strong>, <strong>Stock Quantity</strong></li>
</ul>
<p class="import-note">
Products that don't already exist are created automatically. Dates accept
<code>YYYY-MM-DD</code> or <code>DD/MM/YYYY</code>.
</p>
<button type="button" class="btn-link" onclick={downloadTemplate}>
<FileSpreadsheet size={15} strokeWidth={2.2} /> Download CSV template
</button>
</div>
<div class="import-control">
<label class="file-drop" class:has-file={!!importFile}>
<input
type="file"
accept=".csv,.xlsx,.xlsm,.xls,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onchange={onImportFileChange}
/>
<Upload size={22} strokeWidth={2} />
<span class="file-drop-label">
{importFile ? importFile.name : 'Choose a CSV or .xlsx file'}
</span>
{#if importFile}
<span class="file-drop-size">{(importFile.size / 1024).toFixed(1)} KB</span>
{/if}
</label>
{#if importError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {importError}</p>
{/if}
{#if importResult}
<div class="import-result" role="status">
<p class="import-result-head">
Imported <strong>{importResult.entries_imported}</strong>
{importResult.entries_imported === 1 ? 'entry' : 'entries'}.
</p>
<ul class="import-result-stats">
{#if importResult.products_created > 0}
<li>{importResult.products_created} new product{importResult.products_created === 1 ? '' : 's'} created</li>
{/if}
{#if importResult.entries_skipped > 0}
<li>{importResult.entries_skipped} row{importResult.entries_skipped === 1 ? '' : 's'} skipped</li>
{/if}
</ul>
{#if importResult.errors.length > 0}
<details class="import-errors">
<summary>{importResult.errors.length} issue{importResult.errors.length === 1 ? '' : 's'} to review</summary>
<ul>
{#each importResult.errors as err (err)}
<li>{err}</li>
{/each}
</ul>
</details>
{/if}
</div>
{/if}
<div class="form-footer">
<button class="btn-primary" type="button" disabled={importing || !importFile} onclick={runImport}>
{importing ? 'Importing…' : 'Import entries'}
</button>
</div>
</div>
</div>
</div>
{/if} {/if}
</div> </div>
</AppSecondaryRailLayout> </AppSecondaryRailLayout>
@@ -288,11 +464,173 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* ── Import ─────────────────────────────────────────────────── */
.import-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1.5rem;
padding: 1.5rem 1.75rem;
}
.import-help h3 {
margin: 0 0 0.5rem;
font-size: 0.92rem;
font-weight: 700;
color: var(--text);
}
.import-help p {
margin: 0 0 0.65rem;
font-size: 0.85rem;
line-height: 1.5;
color: var(--muted);
}
.import-help ul {
margin: 0 0 0.65rem;
padding-left: 1.1rem;
display: grid;
gap: 0.3rem;
font-size: 0.84rem;
color: var(--muted);
}
.import-help code {
padding: 0.05rem 0.32rem;
border-radius: 0.35rem;
background: var(--panel-soft);
border: 1px solid var(--line);
font-size: 0.8rem;
}
.import-note {
font-size: 0.82rem;
}
.btn-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0;
background: none;
border: none;
color: var(--color-brand);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.btn-link:hover {
text-decoration: underline;
}
.import-control {
display: grid;
gap: 1rem;
align-content: start;
}
.file-drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem 1.25rem;
border: 1.5px dashed var(--line);
border-radius: 0.75rem;
background: var(--panel-soft);
color: var(--muted);
cursor: pointer;
text-align: center;
transition: border-color 140ms ease, color 140ms ease;
}
.file-drop:hover {
border-color: var(--color-brand);
color: var(--text);
}
.file-drop.has-file {
border-style: solid;
border-color: var(--color-brand);
color: var(--text);
}
.file-drop input {
display: none;
}
.file-drop-label {
font-size: 0.88rem;
font-weight: 600;
word-break: break-all;
}
.file-drop-size {
font-size: 0.78rem;
color: var(--muted);
}
.form-error {
display: flex;
align-items: center;
gap: 0.4rem;
}
.import-result {
padding: 0.85rem 1rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 25%, transparent);
border-radius: 0.6rem;
background: color-mix(in srgb, var(--color-brand) 7%, transparent);
}
.import-result-head {
margin: 0;
font-size: 0.9rem;
color: var(--text);
}
.import-result-stats {
margin: 0.45rem 0 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
font-size: 0.83rem;
color: var(--muted);
}
.import-errors {
margin-top: 0.6rem;
font-size: 0.83rem;
color: var(--muted);
}
.import-errors summary {
cursor: pointer;
font-weight: 600;
color: var(--text);
}
.import-errors ul {
margin: 0.45rem 0 0;
padding-left: 1.1rem;
display: grid;
gap: 0.25rem;
max-height: 12rem;
overflow-y: auto;
}
/* ── Responsive ─────────────────────────────────────────────── */ /* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 720px) { @media (max-width: 720px) {
.field-row { .field-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.import-body {
grid-template-columns: 1fr;
}
} }
</style> </style>
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -5,7 +5,7 @@ import { canOpenThroughput, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) { export async function load({ fetch }) {
if (!hasStoredClientSession()) { if (!hasStoredClientSession()) {
return { entries: [], products: [] }; return { entries: [], statsEntries: [], products: [] };
} }
const session = getStoredClientSession(); const session = getStoredClientSession();
@@ -18,13 +18,22 @@ export async function load({ fetch }) {
recentFrom.setDate(recentFrom.getDate() - 30); recentFrom.setDate(recentFrom.getDate() - 30);
const dateFrom = recentFrom.toISOString().slice(0, 10); const dateFrom = recentFrom.toISOString().slice(0, 10);
// The overview cards are computed in the browser: the hero cards (today /
// this week / 4-week average) plus the customer-mix cards whose rolling range
// the operator can switch up to 12 weeks. Pull that widest window once so
// changing range is a pure re-filter with no extra request.
const statsFrom = new Date();
statsFrom.setDate(statsFrom.getDate() - 84);
const statsDateFrom = statsFrom.toISOString().slice(0, 10);
try { try {
const [entries, products] = await Promise.all([ const [entries, statsEntries, products] = await Promise.all([
api.throughputEntries({ date_from: dateFrom, limit: 200 }, fetch), api.throughputEntries({ date_from: dateFrom, limit: 200 }, fetch),
api.throughputEntries({ date_from: statsDateFrom, limit: 1000 }, fetch),
api.throughputProducts(fetch) api.throughputProducts(fetch)
]); ]);
return { entries, products }; return { entries, statsEntries, products };
} catch { } catch {
return { entries: [], products: [] }; return { entries: [], statsEntries: [], products: [] };
} }
} }
@@ -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;

Some files were not shown because too many files have changed in this diff Show More