Compare commits
6
Commits
4ff372d307
...
7db95e2027
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db95e2027 | ||
|
|
8f9a7b8193 | ||
|
|
250d6ab6a9 | ||
|
|
8b81f804f7 | ||
|
|
b1c0d3f3da | ||
|
|
2de82776cb |
@@ -33,5 +33,12 @@ LOGIN_RATE_LIMIT_ATTEMPTS=8
|
|||||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
||||||
DOCS_ENABLED=false
|
DOCS_ENABLED=false
|
||||||
|
|
||||||
|
# Read-only Power BI / external data API at /api/v1. Set a long random key to
|
||||||
|
# enable it; leave blank to disable the API entirely. Power BI sends this as an
|
||||||
|
# "X-API-Key" header (or "?api_key=" query parameter).
|
||||||
|
POWERBI_API_KEY=V7BI59yhRBF7VMiPNfgmqPxrsPuNuPFJ
|
||||||
|
# Tenant the Power BI API reads from. Defaults to CLIENT_TENANT_ID.
|
||||||
|
POWERBI_TENANT_ID=
|
||||||
|
|
||||||
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
|
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
|
||||||
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
## Repository operations
|
## Repository operations
|
||||||
|
|
||||||
|
### RUles for Svelte
|
||||||
|
If a block has its own UI + state + behaviour, make it a component.
|
||||||
|
If logic is reused or long, move it to a .ts utility file.
|
||||||
|
If CSS is over 300–500 lines, split components.
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
Current app dependency entry points:
|
Current app dependency entry points:
|
||||||
|
|||||||
+431
-11
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()]
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""The Mix Editor must resolve and edit the SAME formula the Mix Calculator reads.
|
||||||
|
|
||||||
|
These cover `resolve_editor_mix_formula` / `resolve_representative_product`: a mix
|
||||||
|
whose product carries its own formula resolves to that product (not the shared
|
||||||
|
mix master), percentages are computed against the total, and the representative
|
||||||
|
product is chosen the way the calculator chooses it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.mix import Mix, MixIngredient
|
||||||
|
from app.models.product import Product, ProductIngredient
|
||||||
|
from app.models.raw_material import RawMaterial
|
||||||
|
from app.services.mix_calculator_service import (
|
||||||
|
resolve_editor_mix_formula,
|
||||||
|
resolve_representative_product,
|
||||||
|
)
|
||||||
|
|
||||||
|
TENANT = "hunter-premium-produce"
|
||||||
|
|
||||||
|
|
||||||
|
def _session() -> Session:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
return sessionmaker(bind=engine, expire_on_commit=False)()
|
||||||
|
|
||||||
|
|
||||||
|
def _raw(db: Session, name: str) -> RawMaterial:
|
||||||
|
material = RawMaterial(tenant_id=TENANT, name=name, unit_of_measure="kg", kg_per_unit=1, status="active")
|
||||||
|
db.add(material)
|
||||||
|
db.flush()
|
||||||
|
return material
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolves_product_formula_not_mix_master():
|
||||||
|
db = _session()
|
||||||
|
bayley = _raw(db, "Bayley")
|
||||||
|
filler = _raw(db, "Filler")
|
||||||
|
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Pigeon Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Shared mix master says something different from the product formula.
|
||||||
|
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=bayley.id, quantity_kg=100))
|
||||||
|
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=filler.id, quantity_kg=100))
|
||||||
|
|
||||||
|
product = Product(tenant_id=TENANT, client_name="Hunter", name="Pigeon 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
|
||||||
|
db.add(product)
|
||||||
|
db.flush()
|
||||||
|
# The calculator's real numbers live here: 787.5 / 1320.41 ~ 59.6%.
|
||||||
|
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=787.5, sort_order=1))
|
||||||
|
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=532.91, sort_order=2))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
|
||||||
|
|
||||||
|
assert formula["source"] == "product"
|
||||||
|
assert formula["product_id"] == product.id
|
||||||
|
assert formula["total_kg"] == 1320.41
|
||||||
|
by_name = {row["raw_material_name"]: row for row in formula["ingredients"]}
|
||||||
|
assert by_name["Bayley"]["quantity_kg"] == 787.5
|
||||||
|
# Percentage matches the worked example (787.5 / 1320.41 * 100).
|
||||||
|
assert abs(by_name["Bayley"]["mix_percentage"] - 59.6406) < 0.001
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_mix_master_when_no_product_formula():
|
||||||
|
db = _session()
|
||||||
|
maize = _raw(db, "Maize")
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
|
||||||
|
assert formula["source"] == "mix"
|
||||||
|
assert formula["total_kg"] == 50
|
||||||
|
assert formula["ingredients"][0]["mix_percentage"] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_representative_product_prefers_20kg_bag():
|
||||||
|
db = _session()
|
||||||
|
maize = _raw(db, "Maize")
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Dual Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
bulka = Product(tenant_id=TENANT, client_name="Hunter", name="Dual Bulka", mix_id=mix.id, unit_of_measure="500kg bulka", visible=True)
|
||||||
|
bag = Product(tenant_id=TENANT, client_name="Hunter", name="Dual 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
|
||||||
|
db.add_all([bulka, bag])
|
||||||
|
db.flush()
|
||||||
|
for product in (bulka, bag):
|
||||||
|
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=maize.id, quantity_kg=20, sort_order=1))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
representative = resolve_representative_product(db, tenant_id=TENANT, mix_id=mix.id)
|
||||||
|
assert representative is not None
|
||||||
|
assert representative.unit_of_measure == "20kg bag"
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Tests for the read-only external data API (`/api/v1`) used by Power BI.
|
||||||
|
|
||||||
|
Drives the real FastAPI app via TestClient against an in-memory database, and
|
||||||
|
swaps the configured API key in by replacing the module-level `settings` object
|
||||||
|
(the real one is a frozen dataclass).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api import public_v1
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import Base, get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.throughput import ProductionThroughput
|
||||||
|
|
||||||
|
API_KEY = "test-powerbi-key"
|
||||||
|
TENANT = "hunter-premium-produce"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_entry(db, *, tenant_id: str, product: str, quantity: float, when: date) -> None:
|
||||||
|
db.add(
|
||||||
|
ProductionThroughput(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
production_date=when,
|
||||||
|
product_name_snapshot=product,
|
||||||
|
bag_size=20,
|
||||||
|
quantity=quantity,
|
||||||
|
quantity_type="bags",
|
||||||
|
calculated_kg=quantity * 20,
|
||||||
|
created_by="test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db_factory():
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = TestingSession()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
yield TestingSession
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(monkeypatch, *, api_key: str) -> TestClient:
|
||||||
|
# Replace the whole settings object the router reads (frozen dataclass).
|
||||||
|
monkeypatch.setattr(public_v1, "settings", replace(settings, powerbi_api_key=api_key, powerbi_tenant_id=TENANT))
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_when_key_unset(monkeypatch, db_factory):
|
||||||
|
client = _make_client(monkeypatch, api_key="")
|
||||||
|
response = client.get("/api/v1/throughput", headers={"X-API-Key": "anything"})
|
||||||
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_missing_and_wrong_key(monkeypatch, db_factory):
|
||||||
|
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||||
|
assert client.get("/api/v1/throughput").status_code == 401
|
||||||
|
assert client.get("/api/v1/throughput", headers={"X-API-Key": "nope"}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_entries_for_tenant(monkeypatch, db_factory):
|
||||||
|
db = db_factory()
|
||||||
|
_seed_entry(db, tenant_id=TENANT, product="Maize", quantity=10, when=date(2026, 1, 5))
|
||||||
|
_seed_entry(db, tenant_id=TENANT, product="Barley", quantity=5, when=date(2026, 1, 6))
|
||||||
|
# An entry in another tenant must never leak through.
|
||||||
|
_seed_entry(db, tenant_id="someone-else", product="Secret", quantity=99, when=date(2026, 1, 7))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||||
|
|
||||||
|
# Header auth.
|
||||||
|
response = client.get("/api/v1/throughput", headers={"X-API-Key": API_KEY})
|
||||||
|
assert response.status_code == 200
|
||||||
|
rows = response.json()
|
||||||
|
names = [row["product_name_snapshot"] for row in rows]
|
||||||
|
assert names == ["Maize", "Barley"] # oldest first, other tenant excluded
|
||||||
|
assert rows[0]["calculated_kg"] == 200.0
|
||||||
|
|
||||||
|
# Query-string auth works too (Power BI Web connector convenience).
|
||||||
|
assert client.get(f"/api/v1/throughput?api_key={API_KEY}").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_filter(monkeypatch, db_factory):
|
||||||
|
db = db_factory()
|
||||||
|
_seed_entry(db, tenant_id=TENANT, product="Old", quantity=1, when=date(2026, 1, 1))
|
||||||
|
_seed_entry(db, tenant_id=TENANT, product="New", quantity=1, when=date(2026, 2, 1))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||||
|
response = client.get("/api/v1/throughput?date_from=2026-01-15", headers={"X-API-Key": API_KEY})
|
||||||
|
assert [row["product_name_snapshot"] for row in response.json()] == ["New"]
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Regression guards for the print/PDF Content-Security-Policy.
|
||||||
|
|
||||||
|
The in-app print dialog loads a generated PDF as a same-origin ``blob:`` URL into
|
||||||
|
an iframe and calls ``contentWindow.print()``. If the CSP omits ``frame-src`` /
|
||||||
|
``child-src`` for ``blob:`` the directive falls back to ``default-src 'self'``,
|
||||||
|
which silently blocks the frame and breaks printing for every user (regardless of
|
||||||
|
role). These tests pin the policy on both layers that emit it:
|
||||||
|
|
||||||
|
* the FastAPI security middleware (covers every API response), and
|
||||||
|
* the production nginx config (the source of the *document* CSP that actually
|
||||||
|
governs ``frame-src`` in the browser).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import issue_token
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
NGINX_CONFIGS = [REPO_ROOT / "deploy" / "nginx" / "clients.lean-101.conf"]
|
||||||
|
|
||||||
|
# Directives the print flow depends on. blob: must be framable, and that must not
|
||||||
|
# come at the cost of dropping the same-origin baseline.
|
||||||
|
REQUIRED_FRAME_SOURCES = {"'self'", "blob:"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csp(header: str) -> dict[str, set[str]]:
|
||||||
|
"""Parse a CSP header string into ``{directive: {sources}}``."""
|
||||||
|
directives: dict[str, set[str]] = {}
|
||||||
|
for part in header.split(";"):
|
||||||
|
tokens = part.split()
|
||||||
|
if not tokens:
|
||||||
|
continue
|
||||||
|
directives[tokens[0].lower()] = set(tokens[1:])
|
||||||
|
return directives
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_blob_framing(header: str) -> None:
|
||||||
|
csp = _parse_csp(header)
|
||||||
|
# frame-src must exist and allow self + blob (no falling back to default-src).
|
||||||
|
assert "frame-src" in csp, f"frame-src missing from CSP: {header!r}"
|
||||||
|
assert REQUIRED_FRAME_SOURCES <= csp["frame-src"], (
|
||||||
|
f"frame-src must allow {REQUIRED_FRAME_SOURCES}, got {csp['frame-src']}"
|
||||||
|
)
|
||||||
|
# child-src is the Safari fallback for frame-src; keep it aligned.
|
||||||
|
assert "child-src" in csp, f"child-src missing from CSP: {header!r}"
|
||||||
|
assert "blob:" in csp["child-src"], f"child-src must allow blob:, got {csp['child-src']}"
|
||||||
|
# We only widened framing: the same-origin default must stay intact.
|
||||||
|
assert csp.get("default-src") == {"'self'"}, f"default-src weakened: {csp.get('default-src')}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Backend middleware policy ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client() -> TestClient:
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_csp_allows_blob_frames(client: TestClient) -> None:
|
||||||
|
response = client.get("/health")
|
||||||
|
assert "content-security-policy" in response.headers
|
||||||
|
_assert_blob_framing(response.headers["content-security-policy"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_csp_present_for_all_users(client: TestClient) -> None:
|
||||||
|
"""The policy is identical for anonymous, authenticated, and rejected (401)
|
||||||
|
requests, so printing can never depend on who is signed in."""
|
||||||
|
admin_token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
|
||||||
|
|
||||||
|
responses = [
|
||||||
|
client.get("/health"), # anonymous
|
||||||
|
client.get("/api/access/me"), # the endpoint that 401s for warehouse users
|
||||||
|
client.get("/api/access/me", headers={"Authorization": f"Bearer {admin_token}"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
policies = set()
|
||||||
|
for response in responses:
|
||||||
|
header = response.headers.get("content-security-policy")
|
||||||
|
assert header is not None, f"CSP missing on {response.request.url} ({response.status_code})"
|
||||||
|
_assert_blob_framing(header)
|
||||||
|
policies.add(header)
|
||||||
|
|
||||||
|
assert len(policies) == 1, "CSP must not vary by authentication state"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Production document policy (nginx) ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_nginx_csp_allows_blob_frames() -> None:
|
||||||
|
"""Every CSP the production nginx emits must allow blob framing. This guards
|
||||||
|
the *document* policy, which is what the browser enforces for the print iframe."""
|
||||||
|
csp_line = re.compile(r'Content-Security-Policy\s+"([^"]+)"', re.IGNORECASE)
|
||||||
|
|
||||||
|
for config_path in NGINX_CONFIGS:
|
||||||
|
assert config_path.exists(), f"missing nginx config: {config_path}"
|
||||||
|
text = config_path.read_text(encoding="utf-8")
|
||||||
|
policies = csp_line.findall(text)
|
||||||
|
assert policies, f"no Content-Security-Policy header found in {config_path}"
|
||||||
|
for policy in policies:
|
||||||
|
_assert_blob_framing(policy)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -458,6 +458,21 @@ fi
|
|||||||
throw
|
throw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Reload nginx config ───────────────────────────────────────────────────
|
||||||
|
# The nginx config is a bind-mounted file. `docker compose up` only recreates
|
||||||
|
# a service when its definition changes, not when a mounted file's contents
|
||||||
|
# change, so a running nginx keeps serving the config it loaded at start. Force
|
||||||
|
# a reload so edits to clients.lean-101.conf (routing, security headers/CSP)
|
||||||
|
# actually take effect on every deploy. Non-fatal: stacks without an nginx
|
||||||
|
# service simply skip this.
|
||||||
|
Write-Step "Reloading nginx to apply config changes"
|
||||||
|
$nginxReload = "cd '$RemotePath' && docker compose $ComposeArgs exec -T nginx nginx -t && docker compose $ComposeArgs exec -T nginx nginx -s reload"
|
||||||
|
if ((Try-Ssh $nginxReload) -eq 0) {
|
||||||
|
Write-Ok "nginx reloaded"
|
||||||
|
} else {
|
||||||
|
Write-Warn "Skipped nginx reload (no nginx service, or config test failed)"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Health check ────────────────────────────────────────────────────────────
|
# ── Health check ────────────────────────────────────────────────────────────
|
||||||
Write-Step "Waiting for backend health check ($BackendContainer)"
|
Write-Step "Waiting for backend health check ($BackendContainer)"
|
||||||
$healthScript = @"
|
$healthScript = @"
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
|
# frame-src/child-src allow same-origin blob: URLs so the in-app PDF print
|
||||||
|
# dialog (an iframe pointed at a blob:) is not blocked by the default-src fallback.
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
|
||||||
location /_app/immutable/ {
|
location /_app/immutable/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
@@ -90,6 +92,14 @@ server {
|
|||||||
location / {
|
location / {
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
add_header Pragma "no-cache" always;
|
add_header Pragma "no-cache" always;
|
||||||
|
# nginx drops inherited add_header directives once a location defines its own,
|
||||||
|
# so the security headers (incl. the blob:-aware CSP) are repeated here to
|
||||||
|
# guarantee the HTML document carries them.
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
|
||||||
expires -1;
|
expires -1;
|
||||||
proxy_pass http://lean101_clients_frontend;
|
proxy_pass http://lean101_clients_frontend;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ services:
|
|||||||
LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8}
|
LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8}
|
||||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300}
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300}
|
||||||
DOCS_ENABLED: ${DOCS_ENABLED:-false}
|
DOCS_ENABLED: ${DOCS_ENABLED:-false}
|
||||||
|
# Read-only Power BI data API (/api/v1). Blank disables it.
|
||||||
|
POWERBI_API_KEY: ${POWERBI_API_KEY:-}
|
||||||
|
POWERBI_TENANT_ID: ${POWERBI_TENANT_ID:-${CLIENT_TENANT_ID:-hunter-premium-produce}}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.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,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": {
|
||||||
|
|||||||
@@ -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: 'What’s 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
@@ -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')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>© {currentYear} Hunter Premium Produce</small>
|
<div class="sidebar-meta-bottom">
|
||||||
<div class="powered-by">
|
<small>© {currentYear} Hunter Premium Produce</small>
|
||||||
<span>Powered by</span>
|
<div class="powered-by">
|
||||||
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
|
<span>Powered by</span>
|
||||||
<strong>Lean 101</strong>
|
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
|
||||||
|
<strong>Lean 101</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{#if logoutConfirmOpen}
|
||||||
|
<div class="logout-dialog-backdrop" aria-hidden="true" onclick={closeLogoutConfirm}></div>
|
||||||
|
<div
|
||||||
|
class="logout-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="logout-dialog-title"
|
||||||
|
aria-describedby="logout-dialog-description"
|
||||||
|
>
|
||||||
|
<div class="logout-dialog-copy">
|
||||||
|
<p class="logout-dialog-kicker">Confirm Logout</p>
|
||||||
|
<h2 id="logout-dialog-title">Log out of the workspace?</h2>
|
||||||
|
<p id="logout-dialog-description">Your current client session will be closed and you will return to sign-in.</p>
|
||||||
|
</div>
|
||||||
|
<div class="logout-dialog-actions">
|
||||||
|
<button type="button" class="logout-dialog-cancel" onclick={closeLogoutConfirm}>Cancel</button>
|
||||||
|
<button type="button" class="logout-dialog-confirm" onclick={confirmSignOut}>Log out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Monochrome rail with a blue selected pill. Colours come from the --sidebar-*
|
/* Monochrome rail with a blue selected pill. Colours come from the --sidebar-*
|
||||||
tokens, which are overridden in dark mode (see theme.css) so the rail themes
|
tokens, which are overridden in dark mode (see theme.css) so the rail themes
|
||||||
@@ -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 ?? ''));
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
Reference in New Issue
Block a user