v0.1.19 - Throughput overview & responsive header
- Throughput: new "Throughput Overview" header with Gauge icon and brand-green badge icons on each card (Today, This week, 4-week average, Horse Mix, Grain Mix) - Throughput: inline rolling-range selector (7d / 4w / 6w / 12w, default 4 weeks) driving the customer-mix cards; stats window widened to 12 weeks so switching range is a pure client-side re-filter - Throughput: cards collapse to a single even 5-across row on laptop and up, with container-query value text that scales to each card's width - Throughput: date logic pinned to Australian Eastern time (fixes the day-early date); This week subtitle shows the Mon-Sun date range - Throughput: subtler tinted add-form; removed the inline-entry kicker and the "Open full form" link - Topbar: fix cramped laptop header - action toggles no longer wrap above the user button; search drops to its own row earlier Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+320
-11
@@ -1,14 +1,21 @@
|
||||
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.orm import Session, joinedload, selectinload
|
||||
|
||||
from app.api.deps import AuthSession, get_auth_session
|
||||
from app.db.session import get_db
|
||||
from app.models.mix import Mix
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.product import Product, ProductIngredient
|
||||
from app.models.raw_material import RawMaterial
|
||||
from app.schemas.editor import (
|
||||
EditorIngredientCreate,
|
||||
EditorIngredientRow,
|
||||
EditorIngredientUpdate,
|
||||
EditorMixFormulaRead,
|
||||
EditorMixIngredientCreate,
|
||||
EditorMixIngredientUpdate,
|
||||
EditorMixRow,
|
||||
EditorMixUpdate,
|
||||
EditorProductFormulaRead,
|
||||
EditorProductIngredientCreate,
|
||||
@@ -17,6 +24,7 @@ from app.schemas.editor import (
|
||||
EditorProductUpdate,
|
||||
)
|
||||
from app.services.client_access_service import has_access_level
|
||||
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
|
||||
|
||||
router = APIRouter(prefix="/api/editor", tags=["editor"])
|
||||
|
||||
@@ -63,6 +71,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:
|
||||
return db.scalar(
|
||||
select(Product)
|
||||
@@ -156,7 +223,34 @@ def update_editor_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.patch("/mixes/{mix_id}", response_model=EditorMixRow)
|
||||
def update_editor_mix(
|
||||
mix_id: int,
|
||||
payload: EditorMixUpdate,
|
||||
@@ -167,18 +261,120 @@ def update_editor_mix(
|
||||
if mix is None:
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
products = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||
.options(joinedload(Product.mix))
|
||||
.order_by(Product.client_name, Product.name, Product.id)
|
||||
).all()
|
||||
return [_serialize_row(product) for product in products]
|
||||
counts = _mix_product_counts(db, session.tenant_id or "")
|
||||
total, visible_count = counts.get(mix_id, (0, 0))
|
||||
return _serialize_mix_row(mix, visible_count=visible_count, product_count=total)
|
||||
|
||||
|
||||
@router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead)
|
||||
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("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
|
||||
@@ -282,3 +478,116 @@ def delete_editor_product_ingredient(
|
||||
|
||||
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
|
||||
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
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
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.orm import Session, selectinload
|
||||
|
||||
@@ -27,6 +28,7 @@ from app.models.ordering import (
|
||||
PriceListItem,
|
||||
PriceTier,
|
||||
ProductCategory,
|
||||
XeroContactLink,
|
||||
XeroSyncLog,
|
||||
)
|
||||
from app.schemas.ordering import (
|
||||
@@ -47,6 +49,7 @@ from app.schemas.ordering import (
|
||||
PriceListItemUpsert,
|
||||
ReopenOrderRequest,
|
||||
VisibilityUpdate,
|
||||
XeroContactLinkUpsert,
|
||||
)
|
||||
from app.services import ordering_service as svc
|
||||
from app.services.client_access_service import (
|
||||
@@ -54,7 +57,11 @@ from app.services.client_access_service import (
|
||||
record_audit_event,
|
||||
)
|
||||
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"])
|
||||
|
||||
@@ -122,11 +129,21 @@ def _serialize_tiers(db: Session, *, customer_product_price_id=None, price_list_
|
||||
# --- 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:
|
||||
users = db.scalars(select(ClientUser).where(ClientUser.client_account_id == account.id)).all()
|
||||
assignment = db.scalar(
|
||||
select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == account.id)
|
||||
)
|
||||
link = _xero_link_for(db, account.id)
|
||||
return {
|
||||
"id": account.id,
|
||||
"name": account.name,
|
||||
@@ -137,6 +154,8 @@ def _serialize_customer(db: Session, account: ClientAccount) -> dict:
|
||||
"user_count": len(users),
|
||||
"price_list_id": assignment.price_list_id if assignment else None,
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -905,7 +924,10 @@ def send_order_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")
|
||||
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(
|
||||
XeroSyncLog(
|
||||
@@ -985,8 +1007,19 @@ def get_xero_status(
|
||||
recent = db.scalars(
|
||||
select(XeroSyncLog).where(XeroSyncLog.tenant_id == TENANT).order_by(XeroSyncLog.created_at.desc()).limit(20)
|
||||
).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 {
|
||||
"connection": xero_status_snapshot(),
|
||||
"contact_links": {
|
||||
"linked": linked_customers,
|
||||
"total": total_customers,
|
||||
"unlinked": max(total_customers - linked_customers, 0),
|
||||
},
|
||||
"recent_syncs": [
|
||||
{
|
||||
"id": log.id,
|
||||
@@ -999,3 +1032,129 @@ def get_xero_status(
|
||||
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)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
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.orm import Session
|
||||
|
||||
@@ -13,16 +13,21 @@ from app.schemas.throughput import (
|
||||
ThroughputEntryCreate,
|
||||
ThroughputEntryRead,
|
||||
ThroughputEntryUpdate,
|
||||
ThroughputImportResult,
|
||||
ThroughputProductCreate,
|
||||
ThroughputProductRead,
|
||||
ThroughputProductUpdate,
|
||||
)
|
||||
from app.services.throughput_service import (
|
||||
calculate_kg,
|
||||
import_entries_from_file,
|
||||
normalise_staff_name,
|
||||
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"])
|
||||
|
||||
MODULE_KEY = "operations_throughput"
|
||||
@@ -184,6 +189,33 @@ def create_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)
|
||||
def get_entry(
|
||||
entry_id: int,
|
||||
@@ -233,7 +265,10 @@ def update_entry(
|
||||
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_entry(
|
||||
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),
|
||||
):
|
||||
entry = db.scalar(
|
||||
|
||||
@@ -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", "job_number", "VARCHAR(64)"),
|
||||
("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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ class MixCalculatorSessionLine(Base):
|
||||
required_kg: Mapped[float] = mapped_column(Float)
|
||||
mix_percentage: Mapped[float] = mapped_column(Float)
|
||||
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)
|
||||
|
||||
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
|
||||
|
||||
@@ -376,6 +376,34 @@ class NotificationSetting(Base):
|
||||
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):
|
||||
__tablename__ = "xero_sync_log"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
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 app.db.session import Base
|
||||
@@ -18,6 +18,9 @@ class RawMaterial(Base):
|
||||
unit_of_measure: Mapped[str] = mapped_column(String(64))
|
||||
kg_per_unit: Mapped[float] = mapped_column(Float)
|
||||
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)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
@@ -36,6 +38,52 @@ class EditorMixUpdate(BaseModel):
|
||||
client_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
# 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 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):
|
||||
@@ -71,3 +119,46 @@ class EditorProductFormulaRead(BaseModel):
|
||||
mix_name: str
|
||||
ingredients: list[EditorProductIngredientRead]
|
||||
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
|
||||
mix_percentage: float
|
||||
unit: str
|
||||
rounding_decimals: int = 2
|
||||
sort_order: int
|
||||
|
||||
|
||||
|
||||
@@ -238,6 +238,18 @@ class NotificationSettingsUpdate(BaseModel):
|
||||
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 ------------------------------------------------
|
||||
|
||||
_CUSTOMER_STATUSES = {"active", "disabled"}
|
||||
|
||||
@@ -117,6 +117,13 @@ class ThroughputEntryUpdate(BaseModel):
|
||||
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):
|
||||
id: int
|
||||
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),
|
||||
)
|
||||
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
|
||||
if note_lines:
|
||||
|
||||
@@ -43,6 +43,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
||||
"raw_material_name": ingredient.raw_material.name,
|
||||
"quantity_kg": ingredient.quantity_kg,
|
||||
"unit": ingredient.raw_material.unit_of_measure,
|
||||
"rounding_decimals": ingredient.raw_material.rounding_decimals,
|
||||
"sort_order": ingredient.sort_order,
|
||||
}
|
||||
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}",
|
||||
"quantity_kg": ingredient.quantity_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,
|
||||
}
|
||||
for index, ingredient in enumerate(product.mix.ingredients, start=1)
|
||||
@@ -128,6 +130,7 @@ def calculate_mix_calculator_preview(
|
||||
"required_kg": required_kg,
|
||||
"mix_percentage": mix_percentage,
|
||||
"unit": ingredient["unit"],
|
||||
"rounding_decimals": ingredient.get("rounding_decimals", 2),
|
||||
"sort_order": ingredient["sort_order"] or index,
|
||||
}
|
||||
)
|
||||
@@ -260,6 +263,7 @@ def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_
|
||||
"required_kg": round(line.required_kg, 4),
|
||||
"mix_percentage": round(line.mix_percentage, 4),
|
||||
"unit": line.unit,
|
||||
"rounding_decimals": line.rounding_decimals,
|
||||
"sort_order": line.sort_order,
|
||||
}
|
||||
for line in session_record.lines
|
||||
@@ -331,6 +335,7 @@ def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, pay
|
||||
required_kg=line["required_kg"],
|
||||
mix_percentage=line["mix_percentage"],
|
||||
unit=line["unit"],
|
||||
rounding_decimals=line.get("rounding_decimals", 2),
|
||||
sort_order=line["sort_order"],
|
||||
)
|
||||
for line in preview["lines"]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
@@ -369,3 +371,310 @@ def resolve_workbook_path() -> Path | None:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
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_bool(cell(row, "for_order")) if field_index.get("for_order") is not None else False
|
||||
for_stock = _coerce_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 app.models.client_access import ClientAccount
|
||||
from app.models.ordering import Order
|
||||
from app.models.ordering import Order, XeroContactLink
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -57,11 +57,75 @@ class XeroSubmissionResult:
|
||||
line_items: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def map_customer_to_contact(customer: ClientAccount) -> dict:
|
||||
"""Map a customer account onto a Xero contact payload."""
|
||||
@dataclass
|
||||
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 {
|
||||
# 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,
|
||||
"Name": customer.name,
|
||||
}
|
||||
@@ -76,7 +140,9 @@ def map_product_to_item_code(product_sku: str) -> str:
|
||||
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."""
|
||||
line_items = []
|
||||
for line in order.lines:
|
||||
@@ -98,7 +164,7 @@ def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
|
||||
return {
|
||||
"Type": "ACCREC",
|
||||
"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}",
|
||||
"LineAmountTypes": "Exclusive",
|
||||
"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.
|
||||
|
||||
Never raises — failures are returned as ``status="failed"`` results so the
|
||||
order lifecycle can record the attempt and continue.
|
||||
Pass ``link`` to invoice against the customer's mapped Xero contact. Never
|
||||
raises — failures are returned as ``status="failed"`` results so the order
|
||||
lifecycle can record the attempt and continue.
|
||||
"""
|
||||
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']}"
|
||||
|
||||
if not config.configured:
|
||||
|
||||
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "data-entry-app-backend"
|
||||
version = "0.1.14"
|
||||
version = "0.1.19"
|
||||
description = "Costing platform MVP backend"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1.0",
|
||||
"python-multipart>=0.0.9,<1.0",
|
||||
"openpyxl>=3.1,<4.0",
|
||||
"rich>=13.9,<15.0",
|
||||
"uvicorn[standard]>=0.30,<1.0",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.18",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"lucide-svelte": "^1.0.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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);
|
||||
}
|
||||
};
|
||||
}
|
||||
+109
-2
@@ -9,6 +9,11 @@ import type {
|
||||
ClientUserUpdateInput,
|
||||
LoginResponse,
|
||||
EditorMixUpdateInput,
|
||||
EditorMixRow,
|
||||
EditorMixFormula,
|
||||
EditorIngredientRow,
|
||||
EditorIngredientCreateInput,
|
||||
EditorIngredientUpdateInput,
|
||||
EditorProductFormula,
|
||||
EditorProductRow,
|
||||
EditorProductUpdateInput,
|
||||
@@ -38,10 +43,14 @@ import type {
|
||||
OrderingCustomerUser,
|
||||
OrderingNotificationSettings,
|
||||
XeroStatus,
|
||||
XeroContactList,
|
||||
XeroContactLinkRow,
|
||||
Scenario,
|
||||
ThroughputEntry,
|
||||
ThroughputEntryCreateInput,
|
||||
ThroughputEntryUpdateInput,
|
||||
ThroughputEntryListParams,
|
||||
ThroughputImportResult,
|
||||
ThroughputProduct,
|
||||
ThroughputProductCreateInput,
|
||||
ThroughputProductUpdateInput
|
||||
@@ -250,6 +259,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(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
@@ -330,11 +378,36 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, '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);
|
||||
},
|
||||
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
|
||||
request<EditorProductRow[]>(`/api/editor/mixes/${mixId}`, {
|
||||
request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
editorMixFormula: (mixId: number) =>
|
||||
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, '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) =>
|
||||
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {}, 'client'),
|
||||
addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
|
||||
@@ -351,6 +424,18 @@ export const api = {
|
||||
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
|
||||
method: 'DELETE'
|
||||
}, '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) =>
|
||||
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
|
||||
productCostingItems: (fetcher?: ApiFetch) =>
|
||||
@@ -391,6 +476,18 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, '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) =>
|
||||
request<ThroughputProduct>('/api/throughput/products', {
|
||||
method: 'POST',
|
||||
@@ -579,6 +676,16 @@ export const api = {
|
||||
cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher),
|
||||
updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) =>
|
||||
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')
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import packageInfo from '../../package.json';
|
||||
*/
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
/** ISO date (YYYY-MM-DD) the version shipped. */
|
||||
/** ISO date (YYYY-MM-DD) the version shipped. */a
|
||||
date: string;
|
||||
highlights: string[];
|
||||
};
|
||||
@@ -17,14 +17,28 @@ export type ChangelogEntry = {
|
||||
export const APP_VERSION: string = packageInfo.version;
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
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',
|
||||
date: '2026-06-11',
|
||||
highlights: [
|
||||
'New: private B2B customer ordering portal — customers browse their catalogue, see account-specific pricing, and submit orders.',
|
||||
'Order management console for internal staff: review orders, manage products, pricing, and the full order lifecycle.',
|
||||
'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.'
|
||||
'Web App: Improved mix calculator',
|
||||
'Web App: Improved design'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,43 +9,146 @@
|
||||
</script>
|
||||
|
||||
{#if blocked}
|
||||
<section class="auth-gate-card">
|
||||
<div class="auth-gate-screen">
|
||||
<section class="auth-gate-card" role="status" aria-live="polite">
|
||||
<div class="auth-gate-loader" aria-hidden="true">
|
||||
<span class="ring"></span>
|
||||
<span class="ring ring-2"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
<p class="auth-gate-label">{label}</p>
|
||||
<h2>{title}</h2>
|
||||
<p>{detail}</p>
|
||||
<p class="auth-gate-detail">{detail}</p>
|
||||
</section>
|
||||
</div>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.auth-gate-screen {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-gate-card {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.35rem 1.4rem;
|
||||
width: min(26rem, 100%);
|
||||
padding: 2.1rem 2rem 2.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
border-radius: 1.25rem;
|
||||
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 {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auth-gate-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.18rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.auth-gate-card p:last-child {
|
||||
.auth-gate-detail {
|
||||
margin: 0;
|
||||
max-width: 22rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
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>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
import {
|
||||
canCreateMixSession as sessionCanCreateMixSession,
|
||||
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
|
||||
canOpenClientAccess as sessionCanOpenClientAccess,
|
||||
canOpenDashboard as sessionCanOpenDashboard,
|
||||
canOpenEditor as sessionCanOpenEditor,
|
||||
canOpenMixCalculator as sessionCanOpenMixCalculator,
|
||||
@@ -32,21 +31,24 @@
|
||||
isWorkspaceRouteAllowed
|
||||
} from '$lib/workspace-access';
|
||||
import {
|
||||
accessControlItem,
|
||||
baseSearchItems,
|
||||
buildClientNavEntries,
|
||||
clientBreadcrumbs,
|
||||
dashboardItem,
|
||||
editorItem,
|
||||
ingredientsEditorItem,
|
||||
footerLinks,
|
||||
matchesRoute,
|
||||
mixCalculatorItem,
|
||||
orderingItem,
|
||||
orderingManageChildren,
|
||||
orderingManageGroup,
|
||||
pageTitle,
|
||||
productCostingItem,
|
||||
reportingItem,
|
||||
throughputItem,
|
||||
type FooterLink,
|
||||
type NavEntry,
|
||||
type SearchItem,
|
||||
type NavItem,
|
||||
workingDocumentItems
|
||||
@@ -89,7 +91,6 @@
|
||||
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
|
||||
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
|
||||
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
|
||||
const canOpenClientAccess = $derived(sessionCanOpenClientAccess($clientSession));
|
||||
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
|
||||
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
|
||||
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
|
||||
@@ -116,15 +117,19 @@
|
||||
// (/ordering/manage), customers get the catalogue (/ordering).
|
||||
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
|
||||
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
|
||||
const visibleOrderingItem = $derived(
|
||||
// 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
|
||||
? { ...orderingItem, href: '/ordering/manage', label: 'Order Management', shortLabel: 'OM' }
|
||||
? { kind: 'group', group: orderingManageGroup }
|
||||
: canOpenCustomerOrdering
|
||||
? orderingItem
|
||||
? { kind: 'item', item: orderingItem }
|
||||
: null
|
||||
);
|
||||
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
|
||||
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
|
||||
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
|
||||
// Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the
|
||||
// standalone operations/insights modules. Built from the same access-filtered
|
||||
// items, so a role only ever sees the families it may open.
|
||||
@@ -135,20 +140,18 @@
|
||||
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
|
||||
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
|
||||
...(visibleEditorItem ? [visibleEditorItem] : []),
|
||||
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
|
||||
...visibleWorkingDocumentItems
|
||||
],
|
||||
throughput: visibleThroughputItem,
|
||||
ordering: visibleOrderingItem,
|
||||
ordering: visibleOrderingEntry,
|
||||
reporting: visibleReportingItem
|
||||
})
|
||||
);
|
||||
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
|
||||
const workspaceRole = $derived(getWorkspaceRole($clientSession));
|
||||
const visibleFooterLinks = $derived([
|
||||
...(!isOperationsUser ? footerLinks : []),
|
||||
...(!canOpenClientAccess
|
||||
? []
|
||||
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel, icon: accessControlItem.icon }])
|
||||
...(!isOperationsUser ? footerLinks : [])
|
||||
] as FooterLink[]);
|
||||
const primaryBottomNavigation = $derived(
|
||||
[
|
||||
@@ -169,6 +172,7 @@
|
||||
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;
|
||||
@@ -219,12 +223,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSearchItems = $derived(
|
||||
// The palette previews at most this many rows. An admin can see every mix and
|
||||
// session, so an unfiltered list would flood the dropdown; capping it keeps
|
||||
// the preview short and pushes the user to type to narrow the match set.
|
||||
const PALETTE_RESULT_LIMIT = 10;
|
||||
const matchingSearchItems = $derived(
|
||||
searchItems.filter((item) => {
|
||||
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
|
||||
return haystack.includes(paletteQuery.trim().toLowerCase());
|
||||
})
|
||||
);
|
||||
const filteredSearchItems = $derived(matchingSearchItems.slice(0, PALETTE_RESULT_LIMIT));
|
||||
const hiddenResultCount = $derived(matchingSearchItems.length - filteredSearchItems.length);
|
||||
|
||||
$effect(() => {
|
||||
page.url.pathname;
|
||||
@@ -476,6 +486,7 @@
|
||||
}}
|
||||
onOpenSettings={openSettings}
|
||||
onSignOut={signOut}
|
||||
onShowWhatsNew={() => (whatsNewOpen = true)}
|
||||
/>
|
||||
|
||||
<main class="content">
|
||||
@@ -627,6 +638,29 @@
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if canManageOrdering}
|
||||
{@const GroupIcon = orderingManageGroup.icon}
|
||||
<a class:active={page.url.pathname === '/ordering/manage'} href="/ordering/manage" onclick={() => (navOpen = false)}>
|
||||
<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, page.url.pathname, child.exact)} href={child.href} onclick={() => (navOpen = false)}>
|
||||
<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, page.url.pathname)} href={orderingItem.href} onclick={() => (navOpen = false)}>
|
||||
<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}
|
||||
@@ -724,6 +758,9 @@
|
||||
<small>{item.href}</small>
|
||||
</button>
|
||||
{/each}
|
||||
{#if hiddenResultCount > 0}
|
||||
<p class="palette-more">{hiddenResultCount} more {hiddenResultCount === 1 ? 'match' : 'matches'} — keep typing to narrow.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="palette-empty">
|
||||
<strong>No results</strong>
|
||||
@@ -1203,6 +1240,14 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.palette-more {
|
||||
margin: 0.25rem 0.4rem 0.15rem;
|
||||
padding: 0.5rem 0.52rem 0.2rem;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.bottom-nav,
|
||||
.bottom-drawer {
|
||||
display: none;
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
<td>
|
||||
<strong>{line.raw_material_name}</strong>
|
||||
</td>
|
||||
<td>{formatNumber(line.required_kg, 2)}kg</td>
|
||||
<td>{formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Moon, Sun } from 'lucide-svelte';
|
||||
import { resolvedTheme, toggleTheme } from '$lib/theme';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
|
||||
const isDark = $derived($resolvedTheme === 'dark');
|
||||
const label = $derived(
|
||||
isDark ? 'Switch to light mode' : 'Switch to dark mode'
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="theme-toggle"
|
||||
type="button"
|
||||
onclick={toggleTheme}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
aria-label={label}
|
||||
use:tooltip={label}
|
||||
>
|
||||
{#if isDark}
|
||||
<Sun size={18} strokeWidth={1.75} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Download, Printer } from 'lucide-svelte';
|
||||
import { ArrowUpDown, Download, Printer } from 'lucide-svelte';
|
||||
import { formatDate, formatNumber } from '$lib/format';
|
||||
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
|
||||
|
||||
@@ -15,6 +15,37 @@
|
||||
onDownloadPdf?: (() => void) | null;
|
||||
} = $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>
|
||||
|
||||
<article class="result-card">
|
||||
@@ -81,17 +112,37 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raw material</th>
|
||||
<th>Required kg</th>
|
||||
<th aria-sort={ariaSort('raw_material_name')}>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each preview.lines as line}
|
||||
{#each sortedLines as line}
|
||||
<tr>
|
||||
<td data-label="Raw material">
|
||||
<strong>{line.raw_material_name}</strong>
|
||||
</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>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -278,6 +329,42 @@
|
||||
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(--text);
|
||||
}
|
||||
|
||||
.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,
|
||||
.secondary-button {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
|
||||
.secondary-rail-layout-content > :global(*) {
|
||||
flex: 1 0 auto;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { ChevronDown, LogOut, Settings } from 'lucide-svelte';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
@@ -68,35 +69,133 @@
|
||||
return null;
|
||||
});
|
||||
|
||||
// Open the active group once each time it changes. Because this only fires on
|
||||
// a *change* of activeGroupId, a user who manually closes the group they're
|
||||
// standing in won't have it reopened under them.
|
||||
// Open the active group once each time it changes, collapsing any other open
|
||||
// group so only one family is ever expanded (accordion). Because this only
|
||||
// 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(() => {
|
||||
const id = activeGroupId;
|
||||
if (id && lastAutoExpanded !== id) {
|
||||
if (!openGroups[id]) {
|
||||
openGroups[id] = true;
|
||||
if (id) {
|
||||
if (lastAutoExpanded !== id) {
|
||||
openGroups = { [id]: true };
|
||||
persistOpenState();
|
||||
}
|
||||
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;
|
||||
|
||||
// Accordion: opening a group collapses every other group; closing just shuts
|
||||
// the one. So expanding Order Management folds an already-open Costing away.
|
||||
function toggleGroup(id: string) {
|
||||
openGroups[id] = !isOpen(id);
|
||||
openGroups = isOpen(id) ? {} : { [id]: true };
|
||||
persistOpenState();
|
||||
}
|
||||
|
||||
const moduleCount = $derived.by(() =>
|
||||
entries.reduce((count, entry) => count + (entry.kind === 'item' ? 1 : entry.group.children.length), 0)
|
||||
);
|
||||
// Expand a group without ever collapsing it. Used by a linkable group header
|
||||
// (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);
|
||||
|
||||
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();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet leafLink(item: NavItem, showIcon: boolean)}
|
||||
{@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)} href={item.href}>
|
||||
{#if showIcon && Icon}
|
||||
<span class="rail-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
{/if}
|
||||
@@ -120,14 +219,12 @@
|
||||
<span class="brand-wordmark">Hunter Premium Produce</span>
|
||||
<span class="brand-subtitle">Operations workspace</span>
|
||||
</a>
|
||||
<span class="module-pill">{moduleCount} modules</span>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-body">
|
||||
<div class="rail-scroll">
|
||||
<div class="rail-section-head">
|
||||
<p class="rail-section-label">Modules</p>
|
||||
<span class="rail-section-count">{moduleCount}</span>
|
||||
</div>
|
||||
|
||||
<nav class="rail-nav" aria-label="Workspace navigation">
|
||||
@@ -139,7 +236,38 @@
|
||||
{@const GroupIcon = group.icon}
|
||||
{@const groupActive = groupHasActiveChild(group, currentPath)}
|
||||
{@const open = isOpen(group.id)}
|
||||
{@const headerHref = group.href ?? group.children[0]?.href}
|
||||
<div class="rail-group">
|
||||
{#if headerHref}
|
||||
<!-- Every family header both navigates and reveals its submenu:
|
||||
the label goes to the family's landing route (Order Management
|
||||
→ its queue; Costing → its first tool) and opens the child
|
||||
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"
|
||||
@@ -150,17 +278,54 @@
|
||||
<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-group-count">{group.children.length}</span>
|
||||
<span class="rail-chevron" class:open aria-hidden="true">
|
||||
<ChevronDown size={15} strokeWidth={2} />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if open}
|
||||
<div class="rail-children">
|
||||
{#each group.children as child}
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -255,20 +420,6 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -309,20 +460,6 @@
|
||||
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 ─────────────────────────────────────────── */
|
||||
.rail-nav {
|
||||
display: grid;
|
||||
@@ -425,32 +562,91 @@
|
||||
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 {
|
||||
color: var(--sidebar-text-strong);
|
||||
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);
|
||||
}
|
||||
|
||||
.rail-group-toggle.within-active .rail-icon,
|
||||
.rail-group-toggle.within-active .rail-chevron {
|
||||
/* ── Linkable group header (label links, chevron toggles) ────── */
|
||||
/* 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);
|
||||
}
|
||||
|
||||
@@ -493,6 +689,23 @@
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -531,14 +744,20 @@
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.sidebar-meta-top,
|
||||
.sidebar-meta-bottom {
|
||||
.sidebar-meta-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.sidebar-meta-bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.sidebar-meta-foot small {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Settings } from 'lucide-svelte';
|
||||
import { Settings, Sparkles } from 'lucide-svelte';
|
||||
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
import type { AppSession } from '$lib/session';
|
||||
import type { Crumb } from '$lib/navigation/client-navigation';
|
||||
|
||||
@@ -18,7 +19,8 @@
|
||||
onOpenPalette,
|
||||
onToggleUserMenu,
|
||||
onOpenSettings,
|
||||
onSignOut
|
||||
onSignOut,
|
||||
onShowWhatsNew
|
||||
}: {
|
||||
breadcrumbs: Crumb[];
|
||||
title: string;
|
||||
@@ -32,6 +34,7 @@
|
||||
onToggleUserMenu: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onSignOut: () => void;
|
||||
onShowWhatsNew: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
@@ -64,6 +67,16 @@
|
||||
{/if}
|
||||
|
||||
<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 />
|
||||
|
||||
<div class="menu-wrap user-menu-wrap">
|
||||
@@ -122,7 +135,10 @@
|
||||
<style>
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(20rem, 36rem) 1fr;
|
||||
/* Left flexes/truncates, search shrinks within a cap, and the actions take
|
||||
exactly their content width (auto) so they're never squeezed into
|
||||
wrapping. */
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 30rem) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.72rem 1.2rem;
|
||||
@@ -141,7 +157,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -149,6 +166,16 @@
|
||||
height: 2rem;
|
||||
width: auto;
|
||||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-copy h1 {
|
||||
@@ -156,6 +183,11 @@
|
||||
font-size: 1.34rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
/* Yield gracefully when space is tight rather than pushing the actions
|
||||
into a wrap. */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
@@ -202,11 +234,33 @@
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.68rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
/* Never let the What's-new / theme toggles wrap above the user button. */
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-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 {
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
@@ -390,7 +444,10 @@
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
/* Drop the search to its own row early: with the 252px sidebar present, a
|
||||
laptop's content width is already tight well above the sidebar's own
|
||||
collapse point, so keep the top row to brand + actions only. */
|
||||
@media (max-width: 1280px) {
|
||||
.topbar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
@@ -417,13 +474,19 @@
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
padding-right: 0.6rem;
|
||||
padding-right: 1.1rem;
|
||||
}
|
||||
|
||||
.topbar-brand img {
|
||||
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 {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
|
||||
@@ -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.
|
||||
$effect(() => {
|
||||
if (selected && clientName && (selected.client_name ?? '') !== clientName) {
|
||||
@@ -120,12 +129,11 @@
|
||||
</script>
|
||||
|
||||
<div class="picker" bind:this={root} onfocusin={() => (focused = true)} onfocusout={onFocusOut}>
|
||||
<div class="client-row">
|
||||
<label class="client-label" for={`${inputId}-client`}>Client</label>
|
||||
<select
|
||||
id={`${inputId}-client`}
|
||||
class="client-select"
|
||||
bind:value={clientName}
|
||||
aria-label="Filter by client"
|
||||
{disabled}
|
||||
>
|
||||
<option value="">All clients</option>
|
||||
@@ -133,7 +141,6 @@
|
||||
<option value={client}>{client}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -196,30 +203,23 @@
|
||||
</div>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 0.45rem;
|
||||
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 {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
flex: 0 1 8.5rem;
|
||||
min-width: 6rem;
|
||||
min-height: 48px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 0.5rem;
|
||||
border-radius: 0.55rem;
|
||||
font: inherit;
|
||||
background: var(--color-bg-surface, #fff);
|
||||
color: var(--color-text-primary, #111827);
|
||||
@@ -228,8 +228,17 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.picker {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.client-select {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
.combo-icon {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
@@ -276,7 +285,7 @@
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
z-index: 200;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
|
||||
@@ -2,12 +2,20 @@ import {
|
||||
BadgeDollarSign,
|
||||
Calculator,
|
||||
ClipboardPenLine,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
ListOrdered,
|
||||
Package,
|
||||
Plug,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
TrendingUp
|
||||
SlidersHorizontal,
|
||||
Tags,
|
||||
TrendingUp,
|
||||
Users
|
||||
} from 'lucide-svelte';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
@@ -31,6 +39,18 @@ export type NavItem = {
|
||||
icon: ComponentType;
|
||||
moduleKey?: 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 = {
|
||||
@@ -50,6 +70,14 @@ export type NavGroup = {
|
||||
label: string;
|
||||
icon: ComponentType;
|
||||
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. */
|
||||
@@ -96,6 +124,15 @@ export const editorItem: NavItem = {
|
||||
badge: 'test'
|
||||
};
|
||||
|
||||
export const ingredientsEditorItem: NavItem = {
|
||||
href: '/ingredients',
|
||||
label: 'Ingredients Editor',
|
||||
shortLabel: 'IE',
|
||||
icon: FlaskConical,
|
||||
moduleKey: 'products',
|
||||
badge: 'test'
|
||||
};
|
||||
|
||||
export const reportingItem: NavItem = {
|
||||
href: '/reporting',
|
||||
label: 'Reporting',
|
||||
@@ -121,6 +158,37 @@ export const orderingItem: NavItem = {
|
||||
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[] = [
|
||||
// Mix Master remains available through the existing route and access logic,
|
||||
// but is temporarily hidden from the sidebar.
|
||||
@@ -140,6 +208,7 @@ export const clientNavigationItems: NavItem[] = [
|
||||
productCostingItem,
|
||||
throughputItem,
|
||||
editorItem,
|
||||
ingredientsEditorItem,
|
||||
accessControlItem
|
||||
];
|
||||
|
||||
@@ -155,8 +224,14 @@ export const baseSearchItems: SearchItem[] = [
|
||||
{
|
||||
href: '/editor',
|
||||
label: 'Open Mix Editor',
|
||||
description: 'Edit client, product, and mix naming from one table.',
|
||||
keywords: 'editor products mixes clients names bulk table phf horse manning'
|
||||
description: 'Edit mix names, status, and ingredients from one table.',
|
||||
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: '/',
|
||||
@@ -219,7 +294,7 @@ export function buildClientNavEntries(visible: {
|
||||
dashboard?: NavItem | null;
|
||||
costing: NavItem[];
|
||||
throughput?: NavItem | null;
|
||||
ordering?: NavItem | null;
|
||||
ordering?: NavEntry | null;
|
||||
reporting?: NavItem | null;
|
||||
}): NavEntry[] {
|
||||
const entries: NavEntry[] = [];
|
||||
@@ -236,7 +311,7 @@ export function buildClientNavEntries(visible: {
|
||||
}
|
||||
|
||||
if (visible.ordering) {
|
||||
entries.push({ kind: 'item', item: visible.ordering });
|
||||
entries.push(visible.ordering);
|
||||
}
|
||||
|
||||
if (visible.throughput) {
|
||||
@@ -250,16 +325,47 @@ export function buildClientNavEntries(visible: {
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** True when any of a group's children matches the current route. */
|
||||
export function groupHasActiveChild(group: NavGroup, pathname: string) {
|
||||
return group.children.some((child) => matchesRoute(child.href, pathname));
|
||||
/** True when a row or any of its nested children matches the current route. */
|
||||
function itemOrChildActive(item: NavItem, pathname: string): boolean {
|
||||
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) {
|
||||
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||
/** True when any of a group's children (or grandchildren) matches the route. */
|
||||
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) {
|
||||
if (pathname.startsWith('/ordering/manage')) {
|
||||
const section = findOrderingSection(pathname);
|
||||
return section && section.href !== '/ordering/manage'
|
||||
? `Order Management · ${section.label}`
|
||||
: 'Order Management';
|
||||
}
|
||||
if (pathname.startsWith('/ordering')) return 'Ordering';
|
||||
return clientNavigationItems.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
|
||||
}
|
||||
|
||||
@@ -297,6 +403,20 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
|
||||
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> = {
|
||||
'/raw-materials': 'Raw Materials',
|
||||
'/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,222 @@
|
||||
/*
|
||||
* 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; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.manage-shell .split,
|
||||
.manage-shell .form-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -46,18 +46,20 @@
|
||||
--color-text-secondary: oklch(0.45 0.008 240);
|
||||
--color-text-muted: oklch(0.6 0.01 240);
|
||||
|
||||
/* ── Sidebar: light monochrome rail with the current item shown as the
|
||||
selected pill. Shared across themes so navigation stays consistent. ── */
|
||||
--sidebar-bg: oklch(0.985 0.001 240);
|
||||
--sidebar-hover: oklch(0.952 0.003 240);
|
||||
--sidebar-active-bg: #3290d9;
|
||||
--sidebar-active-text: var(--color-on-brand);
|
||||
--sidebar-border: oklch(0.9 0.004 240);
|
||||
--sidebar-text: oklch(0.34 0.006 240);
|
||||
--sidebar-text-strong: oklch(0.16 0.004 240);
|
||||
--sidebar-text-muted: oklch(0.56 0.008 240);
|
||||
--sidebar-icon: oklch(0.42 0.006 240);
|
||||
--sidebar-logo-bg: oklch(0.98 0.003 240);
|
||||
/* ── Sidebar: deep-green rail matching the customer ordering portal
|
||||
(CustomerPortalShell). The current item shows as a white pill with
|
||||
green text. Held constant across light/dark themes so internal staff
|
||||
and customers see the same navigation styling. ── */
|
||||
--sidebar-bg: #1f3a2c;
|
||||
--sidebar-hover: rgba(255, 255, 255, 0.08);
|
||||
--sidebar-active-bg: #ffffff;
|
||||
--sidebar-active-text: #1f3a2c;
|
||||
--sidebar-border: rgba(255, 255, 255, 0.12);
|
||||
--sidebar-text: rgba(231, 239, 233, 0.85);
|
||||
--sidebar-text-strong: #ffffff;
|
||||
--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 ───────────────────────────────────────────── */
|
||||
--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-divider: oklch(0.28 0.005 240);
|
||||
|
||||
/* ── Sidebar: dark rail tuned to the content theme so it stops
|
||||
rendering as a bright light strip in dark mode. Active item keeps
|
||||
the blue pill from light mode. ── */
|
||||
/* ── Sidebar: in dark mode the rail drops the deep-green and joins the
|
||||
neutral dark surfaces, sitting a touch below the app canvas so it
|
||||
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-hover: oklch(0.27 0.006 240);
|
||||
--sidebar-active-bg: #3290d9;
|
||||
--sidebar-active-text: var(--color-on-brand);
|
||||
--sidebar-active-bg: color-mix(in srgb, var(--color-brand) 22%, var(--color-bg-surface));
|
||||
--sidebar-active-text: oklch(0.9 0.07 162);
|
||||
--sidebar-border: oklch(0.3 0.006 240);
|
||||
--sidebar-text: oklch(0.78 0.006 240);
|
||||
--sidebar-text-strong: oklch(0.96 0.003 240);
|
||||
--sidebar-text-muted: oklch(0.6 0.008 240);
|
||||
--sidebar-icon: oklch(0.68 0.008 240);
|
||||
--sidebar-logo-bg: oklch(0.26 0.006 240);
|
||||
--sidebar-icon: oklch(0.72 0.006 240);
|
||||
--sidebar-logo-bg: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* ── Text (neutral) ─────────────────────────────────────── */
|
||||
--color-text-primary: oklch(0.96 0.003 240);
|
||||
@@ -560,3 +563,49 @@ a {
|
||||
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;
|
||||
mix_percentage: number;
|
||||
unit: string;
|
||||
rounding_decimals?: number;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
@@ -306,6 +307,35 @@ export type EditorMixUpdateInput = {
|
||||
client_name?: string;
|
||||
name?: string;
|
||||
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 EditorProductIngredient = {
|
||||
@@ -328,6 +358,32 @@ export type EditorProductFormula = {
|
||||
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 = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -616,6 +672,15 @@ export type ThroughputEntryCreateInput = {
|
||||
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 = {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
@@ -745,6 +810,8 @@ export type OrderingCustomer = {
|
||||
user_count: number;
|
||||
price_list_id: number | null;
|
||||
discount_percent: number;
|
||||
xero_contact_id?: string | null;
|
||||
xero_contact_name?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
@@ -793,6 +860,7 @@ export type OrderingNotificationSettings = {
|
||||
|
||||
export type XeroStatus = {
|
||||
connection: { configured: boolean; mode: string; base_url: string; checked_at: string; missing_env: string[] };
|
||||
contact_links: { linked: number; total: number; unlinked: number };
|
||||
recent_syncs: {
|
||||
id: number;
|
||||
order_id: number;
|
||||
@@ -802,3 +870,27 @@ export type XeroStatus = {
|
||||
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('/products')) return canOpenProducts(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('/reporting')) return canOpenReporting(session);
|
||||
if (pathname.startsWith('/settings')) return canOpenSettings(session);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { api } from '$lib/api';
|
||||
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';
|
||||
|
||||
const EMPTY_SUMMARY: DashboardSummary = {
|
||||
@@ -22,6 +22,14 @@ export function load({ fetch }) {
|
||||
}
|
||||
|
||||
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)) {
|
||||
throw redirect(307, getWorkspaceHomeHref(session));
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
||||
import type { EditorProductFormula, EditorProductIngredient, EditorProductRow, RawMaterial } from '$lib/types';
|
||||
import SortHeader from '$lib/table/SortHeader.svelte';
|
||||
import { TableController } from '$lib/table/table.svelte';
|
||||
import type {
|
||||
EditorMixFormula,
|
||||
EditorMixIngredient,
|
||||
EditorMixRow,
|
||||
EditorMixUpdateInput,
|
||||
RawMaterial
|
||||
} from '$lib/types';
|
||||
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
type EditableRow = EditorProductRow & {
|
||||
draft_product_name: string;
|
||||
type EditableRow = EditorMixRow & {
|
||||
draft_mix_name: string;
|
||||
draft_sale_type: string;
|
||||
draft_unit_of_measure: string;
|
||||
draft_visible: boolean;
|
||||
};
|
||||
|
||||
@@ -25,24 +30,17 @@
|
||||
|
||||
let rows = $state<EditableRow[]>([]);
|
||||
let query = $state('');
|
||||
let productFilter = $state('');
|
||||
let clientFilter = $state('all');
|
||||
let visibilityFilter = $state<'all' | 'visible' | 'hidden'>('visible');
|
||||
let packFilter = $state('all');
|
||||
let page = $state(1);
|
||||
let pageSize = $state(25);
|
||||
let savingKey = $state<string | null>(null);
|
||||
let expandedProductId = $state<number | null>(null);
|
||||
let activeFormula = $state<EditorProductFormula | null>(null);
|
||||
let expandedMixId = $state<number | null>(null);
|
||||
let activeFormula = $state<EditorMixFormula | null>(null);
|
||||
let ingredientDrafts = $state<DraftIngredient[]>([]);
|
||||
|
||||
function toEditableRow(row: EditorProductRow): EditableRow {
|
||||
function toEditableRow(row: EditorMixRow): EditableRow {
|
||||
return {
|
||||
...row,
|
||||
draft_product_name: row.name,
|
||||
draft_mix_name: row.mix_name,
|
||||
draft_sale_type: row.sale_type,
|
||||
draft_unit_of_measure: row.unit_of_measure,
|
||||
draft_mix_name: row.name,
|
||||
draft_visible: row.visible
|
||||
};
|
||||
}
|
||||
@@ -53,7 +51,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
function ingredientToDraft(ingredient: EditorProductIngredient): DraftIngredient {
|
||||
function ingredientToDraft(ingredient: EditorMixIngredient): DraftIngredient {
|
||||
return {
|
||||
id: ingredient.id,
|
||||
raw_material_id: ingredient.raw_material_id,
|
||||
@@ -71,68 +69,32 @@
|
||||
};
|
||||
}
|
||||
|
||||
function loadIngredientDrafts(formula: EditorProductFormula) {
|
||||
function loadIngredientDrafts(formula: EditorMixFormula) {
|
||||
activeFormula = formula;
|
||||
ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()];
|
||||
}
|
||||
|
||||
function productDirty(row: EditableRow) {
|
||||
return (
|
||||
row.draft_product_name !== row.name ||
|
||||
row.draft_sale_type !== row.sale_type ||
|
||||
row.draft_unit_of_measure !== row.unit_of_measure ||
|
||||
row.draft_visible !== row.visible
|
||||
);
|
||||
}
|
||||
|
||||
function mixDirty(row: EditableRow) {
|
||||
return row.draft_mix_name !== row.mix_name;
|
||||
}
|
||||
|
||||
function rowDirty(row: EditableRow) {
|
||||
return productDirty(row) || mixDirty(row);
|
||||
return row.draft_mix_name !== row.name || row.draft_visible !== row.visible;
|
||||
}
|
||||
|
||||
function applyProductUpdate(updated: EditorProductRow) {
|
||||
function applyMixUpdate(updated: EditorMixRow) {
|
||||
rows = rows.map((row) => (row.id === updated.id ? toEditableRow(updated) : row));
|
||||
}
|
||||
|
||||
function applyMixUpdate(updatedRows: EditorProductRow[]) {
|
||||
const updatedById = new Map(updatedRows.map((row) => [row.id, row]));
|
||||
rows = rows.map((row) => {
|
||||
const updated = updatedById.get(row.id);
|
||||
return updated ? toEditableRow(updated) : row;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveRow(row: EditableRow) {
|
||||
if (!rowDirty(row)) return;
|
||||
savingKey = `row:${row.id}`;
|
||||
|
||||
try {
|
||||
if (productDirty(row)) {
|
||||
applyProductUpdate(
|
||||
await api.updateEditorProduct(row.id, {
|
||||
name: row.draft_product_name.trim(),
|
||||
sale_type: row.draft_sale_type.trim(),
|
||||
unit_of_measure: row.draft_unit_of_measure.trim(),
|
||||
visible: row.draft_visible
|
||||
})
|
||||
);
|
||||
}
|
||||
const payload: EditorMixUpdateInput = {};
|
||||
if (row.draft_mix_name.trim() !== row.name) payload.name = row.draft_mix_name.trim();
|
||||
if (row.draft_visible !== row.visible) payload.visible = row.draft_visible;
|
||||
|
||||
const current = rows.find((candidate) => candidate.id === row.id) ?? row;
|
||||
if (mixDirty(current)) {
|
||||
applyMixUpdate(
|
||||
await api.updateEditorMix(current.mix_id, {
|
||||
name: current.draft_mix_name.trim()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
toast.success('Row saved');
|
||||
applyMixUpdate(await api.updateEditorMix(row.id, payload));
|
||||
toast.success('Mix saved');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to save row');
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to save mix');
|
||||
} finally {
|
||||
savingKey = null;
|
||||
}
|
||||
@@ -143,20 +105,20 @@
|
||||
}
|
||||
|
||||
async function toggleIngredients(row: EditableRow) {
|
||||
if (expandedProductId === row.id) {
|
||||
expandedProductId = null;
|
||||
if (expandedMixId === row.id) {
|
||||
expandedMixId = null;
|
||||
activeFormula = null;
|
||||
ingredientDrafts = [];
|
||||
return;
|
||||
}
|
||||
|
||||
expandedProductId = row.id;
|
||||
savingKey = `product-load:${row.id}`;
|
||||
expandedMixId = row.id;
|
||||
savingKey = `mix-load:${row.id}`;
|
||||
|
||||
try {
|
||||
loadIngredientDrafts(await api.editorProductFormula(row.id));
|
||||
loadIngredientDrafts(await api.editorMixFormula(row.id));
|
||||
} catch (error) {
|
||||
expandedProductId = null;
|
||||
expandedMixId = null;
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to load ingredients');
|
||||
} finally {
|
||||
savingKey = null;
|
||||
@@ -177,7 +139,7 @@
|
||||
.map((row) => row.raw_material_id)
|
||||
.filter((rawMaterialId): rawMaterialId is number => rawMaterialId !== null);
|
||||
|
||||
if (!activeFormula) return ['Open a product before saving ingredients.'];
|
||||
if (!activeFormula) return ['Open a mix before saving ingredients.'];
|
||||
if (!chosen.length) return ['Add at least one raw material.'];
|
||||
if (new Set(chosen).size !== chosen.length) return ['Each raw material can only appear once in a mix.'];
|
||||
|
||||
@@ -197,7 +159,7 @@
|
||||
}
|
||||
if (!activeFormula) return;
|
||||
|
||||
savingKey = `product-save:${activeFormula.id}`;
|
||||
savingKey = `mix-save:${activeFormula.id}`;
|
||||
|
||||
try {
|
||||
const cleanRows = ingredientDrafts.map((row) => ({
|
||||
@@ -212,14 +174,14 @@
|
||||
for (const ingredient of activeFormula.ingredients) {
|
||||
const draft = cleanRows.find((row) => row.id === ingredient.id);
|
||||
if (!keptIds.has(ingredient.id) || (draft && draft.raw_material_id !== ingredient.raw_material_id)) {
|
||||
await api.deleteEditorProductIngredient(activeFormula.id, ingredient.id);
|
||||
await api.deleteEditorMixIngredient(activeFormula.id, ingredient.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of cleanRows) {
|
||||
const original = row.id === null ? null : originalById.get(row.id);
|
||||
if (!original || original.raw_material_id !== row.raw_material_id) {
|
||||
await api.addEditorProductIngredient(activeFormula.id, {
|
||||
await api.addEditorMixIngredient(activeFormula.id, {
|
||||
raw_material_id: row.raw_material_id,
|
||||
quantity_kg: row.quantity_kg,
|
||||
notes: row.notes
|
||||
@@ -228,14 +190,14 @@
|
||||
}
|
||||
|
||||
if (original.quantity_kg !== row.quantity_kg || (original.notes ?? null) !== row.notes) {
|
||||
await api.updateEditorProductIngredient(activeFormula.id, original.id, {
|
||||
await api.updateEditorMixIngredient(activeFormula.id, original.id, {
|
||||
quantity_kg: row.quantity_kg,
|
||||
notes: row.notes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadIngredientDrafts(await api.editorProductFormula(activeFormula.id));
|
||||
loadIngredientDrafts(await api.editorMixFormula(activeFormula.id));
|
||||
toast.success('Ingredients saved');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to save ingredients');
|
||||
@@ -250,25 +212,15 @@
|
||||
|
||||
function clearFilters() {
|
||||
query = '';
|
||||
productFilter = '';
|
||||
clientFilter = 'all';
|
||||
visibilityFilter = 'visible';
|
||||
packFilter = 'all';
|
||||
page = 1;
|
||||
table.reset();
|
||||
}
|
||||
|
||||
function setVisibilityFilter(value: 'all' | 'visible' | 'hidden') {
|
||||
visibilityFilter = value;
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
page = Math.max(1, page - 1);
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
page = Math.min(totalPages, page + 1);
|
||||
}
|
||||
|
||||
const clientOptions = $derived(
|
||||
Array.from(
|
||||
new Set(
|
||||
@@ -286,57 +238,38 @@
|
||||
const visibleRows = $derived(
|
||||
rows.filter((row) => {
|
||||
const term = query.trim().toLowerCase();
|
||||
const productTerm = productFilter.trim().toLowerCase();
|
||||
const clientMatches = clientFilter === 'all' || row.client_name === clientFilter;
|
||||
const productMatches = !productTerm || row.name.toLowerCase().includes(productTerm);
|
||||
const visibilityMatches =
|
||||
visibilityFilter === 'all' ||
|
||||
(visibilityFilter === 'visible' && row.visible) ||
|
||||
(visibilityFilter === 'hidden' && !row.visible);
|
||||
const packMatches = packFilter === 'all' || row.sale_type === packFilter;
|
||||
if (!clientMatches) return false;
|
||||
if (!productMatches || !visibilityMatches || !packMatches) return false;
|
||||
if (!visibilityMatches) return false;
|
||||
if (!term) return true;
|
||||
|
||||
return [
|
||||
row.client_name,
|
||||
row.name,
|
||||
row.item_id ?? '',
|
||||
row.mix_name,
|
||||
row.mix_client_name,
|
||||
row.sale_type,
|
||||
row.unit_of_measure
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(term);
|
||||
return [row.client_name, row.name].join(' ').toLowerCase().includes(term);
|
||||
})
|
||||
);
|
||||
|
||||
const table = new TableController<EditableRow>(() => visibleRows, {
|
||||
client_name: (row) => row.client_name,
|
||||
name: (row) => row.name,
|
||||
visible: (row) => row.visible
|
||||
});
|
||||
|
||||
const dirtyCount = $derived(rows.filter(rowDirty).length);
|
||||
const uniqueMixCount = $derived(new Set(visibleRows.map((row) => row.mix_id)).size);
|
||||
const filtersActive = $derived(Boolean(query.trim() || productFilter.trim() || clientFilter !== 'all' || visibilityFilter !== 'visible' || packFilter !== 'all'));
|
||||
const packOptions = $derived(Array.from(new Set(rows.map((row) => row.sale_type))).sort());
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(visibleRows.length / pageSize)));
|
||||
const pageStart = $derived(visibleRows.length === 0 ? 0 : (Math.min(page, totalPages) - 1) * pageSize + 1);
|
||||
const pageEnd = $derived(Math.min(visibleRows.length, Math.min(page, totalPages) * pageSize));
|
||||
const paginatedRows = $derived(visibleRows.slice(pageStart === 0 ? 0 : pageStart - 1, pageEnd));
|
||||
const filtersActive = $derived(Boolean(query.trim() || clientFilter !== 'all' || visibilityFilter !== 'visible'));
|
||||
const ingredientTotalKg = $derived(
|
||||
ingredientDrafts.reduce((sum, ingredient) => sum + Number(ingredient.quantity_kg || 0), 0)
|
||||
);
|
||||
|
||||
// Jump back to the first page whenever the filtered set changes.
|
||||
$effect(() => {
|
||||
query;
|
||||
productFilter;
|
||||
clientFilter;
|
||||
visibilityFilter;
|
||||
packFilter;
|
||||
pageSize;
|
||||
page = 1;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (page > totalPages) page = totalPages;
|
||||
table.pageSize;
|
||||
table.reset();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -356,8 +289,8 @@
|
||||
<ListFilter size={16} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div class="rail-identity-text">
|
||||
<p class="identity-name">Filter products</p>
|
||||
<p class="identity-role">{visibleRows.length} matching rows</p>
|
||||
<p class="identity-name">Filter mixes</p>
|
||||
<p class="identity-role">{visibleRows.length} matching mixes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -366,7 +299,7 @@
|
||||
<span>Search</span>
|
||||
<div class="search-input">
|
||||
<Search size={17} strokeWidth={2.2} />
|
||||
<input bind:value={query} type="search" placeholder="Client, product, mix, item ID, unit" />
|
||||
<input bind:value={query} type="search" placeholder="Client or mix name" />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -389,21 +322,6 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Product</span>
|
||||
<input bind:value={productFilter} type="search" placeholder="Product name" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Pack</span>
|
||||
<select bind:value={packFilter}>
|
||||
<option value="all">All packs</option>
|
||||
{#each packOptions as option}
|
||||
<option value={option}>{option}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#if filtersActive}
|
||||
<button type="button" class="clear-button rail-clear" onclick={clearFilters}>
|
||||
<X size={16} strokeWidth={2.4} /> Clear filters
|
||||
@@ -418,18 +336,14 @@
|
||||
<div class="editor-status">
|
||||
<span>
|
||||
<strong>Mix Editor</strong>
|
||||
<small>{visibleRows.length} products across {uniqueMixCount} mixes</small>
|
||||
<small>{visibleRows.length} mixes</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="facts">
|
||||
<div class="fact">
|
||||
<dt>Products</dt>
|
||||
<dd>{visibleRows.length}</dd>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<dt>Mixes</dt>
|
||||
<dd>{uniqueMixCount}</dd>
|
||||
<dd>{visibleRows.length}</dd>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<dt>Unsaved</dt>
|
||||
@@ -438,76 +352,47 @@
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="pagination-bar" aria-label="Product table pagination">
|
||||
<span>{pageStart}-{pageEnd} of {visibleRows.length}</span>
|
||||
<div class="pagination-bar" aria-label="Mix table pagination">
|
||||
<span>{table.pageStart}-{table.pageEnd} of {table.total}</span>
|
||||
<label class="page-size">
|
||||
<span>Rows</span>
|
||||
<select bind:value={pageSize}>
|
||||
<option value={10}>10</option>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<select bind:value={table.pageSize}>
|
||||
{#each table.pageSizes as size}
|
||||
<option value={size}>{size}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="page-controls">
|
||||
<button type="button" class="clear-button icon-button" disabled={page <= 1} onclick={previousPage} aria-label="Previous page">
|
||||
<button type="button" class="clear-button icon-button" disabled={!table.canPrev} onclick={() => table.prev()} aria-label="Previous page">
|
||||
<ChevronLeft size={16} strokeWidth={2.4} />
|
||||
</button>
|
||||
<span>Page {Math.min(page, totalPages)} of {totalPages}</span>
|
||||
<button type="button" class="clear-button icon-button" disabled={page >= totalPages} onclick={nextPage} aria-label="Next page">
|
||||
<span>Page {table.currentPage} of {table.totalPages}</span>
|
||||
<button type="button" class="clear-button icon-button" disabled={!table.canNext} onclick={() => table.next()} aria-label="Next page">
|
||||
<ChevronRight size={16} strokeWidth={2.4} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log">
|
||||
<div class="log-head" aria-hidden="true">
|
||||
<span>Client</span>
|
||||
<span>ID</span>
|
||||
<span>Product</span>
|
||||
<span>Mix</span>
|
||||
<span>Pack</span>
|
||||
<span>Unit / Bag</span>
|
||||
<span>Status</span>
|
||||
<div class="log-head">
|
||||
<SortHeader label="Client" column="client_name" controller={table} />
|
||||
<SortHeader label="Mix" column="name" controller={table} />
|
||||
<SortHeader label="Status" column="visible" controller={table} />
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
|
||||
{#each paginatedRows as row (row.id)}
|
||||
{#each table.rows as row (row.id)}
|
||||
<div class="row" class:edited={rowDirty(row)}>
|
||||
<div class="client-cell">
|
||||
<span class="cell-label">Client</span>
|
||||
<span class="readonly-value">{row.client_name}</span>
|
||||
</div>
|
||||
|
||||
<div class="id-cell">
|
||||
<span class="cell-label">ID</span>
|
||||
<span class="id-value">{row.item_id ?? '-'}</span>
|
||||
</div>
|
||||
|
||||
<div class="product-cell">
|
||||
<span class="cell-label">Product</span>
|
||||
<input bind:value={row.draft_product_name} aria-label="End product name" />
|
||||
</div>
|
||||
|
||||
<div class="mix-cell">
|
||||
<span class="cell-label">Mix</span>
|
||||
<input bind:value={row.draft_mix_name} aria-label="Mix name" />
|
||||
</div>
|
||||
|
||||
<div class="pack-cell">
|
||||
<span class="cell-label">Pack</span>
|
||||
<select bind:value={row.draft_sale_type} aria-label="Sale type">
|
||||
<option value="standard">standard</option>
|
||||
<option value="bulka">bulka</option>
|
||||
<option value="per_unit">per_unit</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="unit-cell">
|
||||
<span class="cell-label">Unit</span>
|
||||
<input bind:value={row.draft_unit_of_measure} aria-label="Unit of measure" />
|
||||
</div>
|
||||
|
||||
<div class="status-cell">
|
||||
<span class="cell-label">Status</span>
|
||||
<label class="status-toggle" class:on={row.draft_visible}>
|
||||
@@ -519,11 +404,11 @@
|
||||
<div class="row-actions">
|
||||
<button class="clear-button" type="button" onclick={() => toggleIngredients(row)}>
|
||||
<FlaskConical size={16} strokeWidth={2.2} />
|
||||
{expandedProductId === row.id ? 'Close ingredients' : savingKey === `product-load:${row.id}` ? 'Loading...' : 'Ingredients'}
|
||||
{expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'}
|
||||
</button>
|
||||
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
|
||||
<Save size={16} strokeWidth={2.4} />
|
||||
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save row'}
|
||||
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save mix'}
|
||||
</button>
|
||||
{#if rowDirty(row)}
|
||||
<button class="link-button" type="button" onclick={() => resetRow(row)}>Reset</button>
|
||||
@@ -531,7 +416,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if expandedProductId === row.id}
|
||||
{#if expandedMixId === row.id}
|
||||
<div class="ingredient-panel" transition:fade={{ duration: 120 }}>
|
||||
<div class="ingredient-head">
|
||||
<div>
|
||||
@@ -570,15 +455,15 @@
|
||||
|
||||
<div class="ingredient-footer">
|
||||
<button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button>
|
||||
<button class="apply-button" type="button" disabled={savingKey === `product-save:${row.id}`} onclick={saveIngredients}>
|
||||
{savingKey === `product-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
|
||||
<button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}`} onclick={saveIngredients}>
|
||||
{savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<p>No products match your search</p>
|
||||
<p>No mixes match your search</p>
|
||||
<button type="button" class="clear-button" onclick={clearFilters}>Clear filters</button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -593,7 +478,7 @@
|
||||
gap: 0.9rem;
|
||||
min-height: 100%;
|
||||
padding: 1rem 1.15rem 2rem;
|
||||
background: #e8eee9;
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
:global(.secondary-rail-layout) {
|
||||
@@ -602,7 +487,7 @@
|
||||
|
||||
:global(.secondary-rail-layout-panel),
|
||||
:global(.secondary-rail-layout-content) {
|
||||
background: #e8eee9;
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.filter-rail {
|
||||
@@ -613,7 +498,7 @@
|
||||
gap: 0.25rem;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 8.5rem);
|
||||
background: color-mix(in srgb, var(--panel-soft) 46%, #dfe7e1);
|
||||
background: var(--color-bg-surface);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -621,7 +506,7 @@
|
||||
.rail-label {
|
||||
margin: 0;
|
||||
padding: 1rem 1rem 0.15rem;
|
||||
color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
@@ -643,10 +528,10 @@
|
||||
flex-shrink: 0;
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 22%, var(--color-border));
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--panel) 80%, #edf2ee);
|
||||
color: #6b786f;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.rail-identity-text {
|
||||
@@ -659,7 +544,7 @@
|
||||
}
|
||||
|
||||
.identity-name {
|
||||
color: #526059;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
@@ -668,7 +553,7 @@
|
||||
}
|
||||
|
||||
.identity-role {
|
||||
color: #8a9790;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@@ -690,7 +575,7 @@
|
||||
flex-wrap: wrap;
|
||||
padding: 0.9rem 1.1rem;
|
||||
background: var(--color-brand-tint);
|
||||
border: 1px solid #bfe6c8;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 32%, var(--color-border));
|
||||
border-radius: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -784,7 +669,7 @@
|
||||
}
|
||||
|
||||
.apply-button {
|
||||
color: #fbfdfa;
|
||||
color: var(--color-on-brand);
|
||||
background: var(--color-brand);
|
||||
border: 1px solid var(--color-brand);
|
||||
}
|
||||
@@ -969,13 +854,9 @@
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(150px, 0.9fr)
|
||||
minmax(72px, 0.32fr)
|
||||
minmax(210px, 1.35fr)
|
||||
minmax(170px, 1fr)
|
||||
minmax(105px, 0.55fr)
|
||||
minmax(130px, 0.7fr)
|
||||
minmax(78px, 0.34fr)
|
||||
minmax(220px, 1.6fr)
|
||||
minmax(110px, 0.5fr)
|
||||
minmax(198px, auto);
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
@@ -1002,7 +883,7 @@
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: #fafbfc;
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.row.edited {
|
||||
@@ -1021,29 +902,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.id-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 650;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.client-cell,
|
||||
.id-cell,
|
||||
.product-cell,
|
||||
.mix-cell,
|
||||
.pack-cell,
|
||||
.unit-cell,
|
||||
.status-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1102,7 +966,7 @@
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem;
|
||||
background: #f8fbf8;
|
||||
background: var(--panel-soft);
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
@@ -1146,8 +1010,8 @@
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
color: #8a1622;
|
||||
border-color: #e2a8af;
|
||||
color: var(--color-error);
|
||||
border-color: color-mix(in srgb, var(--color-error) 38%, var(--color-border));
|
||||
}
|
||||
|
||||
.ingredient-footer {
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function load({ fetch }) {
|
||||
|
||||
try {
|
||||
const [rows, rawMaterials] = await Promise.all([
|
||||
api.editorProducts({ limit: 1000 }, fetch),
|
||||
api.editorMixes({ limit: 1000 }, 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: [] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import '$lib/ordering/manage.css';
|
||||
import { findOrderingSection } from '$lib/navigation/client-navigation';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// The header mirrors the active section: "Order Management" eyebrow above, then
|
||||
// the current page's name (Orders, Products, …, or a nested page like Xero).
|
||||
// Reuse the rail's section finder so the title stays in sync with navigation.
|
||||
const sectionLabel = $derived(findOrderingSection(page.url.pathname)?.label ?? 'Orders');
|
||||
</script>
|
||||
|
||||
<!-- Section navigation lives in the primary left rail (and the mobile drawer),
|
||||
so the console pages don't repeat it inline. -->
|
||||
<div class="manage-shell">
|
||||
<header>
|
||||
<p class="eyebrow">Order Management</p>
|
||||
<h1>{sectionLabel}</h1>
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.manage-shell { display: grid; gap: 1rem; }
|
||||
h1 { margin: 0.15rem 0; font-size: 1.4rem; letter-spacing: -0.02em; }
|
||||
.eyebrow { color: var(--color-text-muted); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
</style>
|
||||
@@ -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,16 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import type {
|
||||
CatalogueProduct,
|
||||
CustomerPricing,
|
||||
CustomerVisibilityRow,
|
||||
Order,
|
||||
OrderingCustomer,
|
||||
OrderingCustomerUser,
|
||||
OrderingNotificationSettings,
|
||||
XeroStatus
|
||||
} from '$lib/types';
|
||||
import { money, label, statusTone, ORDER_STATUSES } from '$lib/ordering/format';
|
||||
import type { Order } from '$lib/types';
|
||||
|
||||
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 products = $state<CatalogueProduct[]>([]);
|
||||
let customers = $state<OrderingCustomer[]>([]);
|
||||
let xero = $state<XeroStatus | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
orders = data.orders ?? [];
|
||||
products = data.products ?? [];
|
||||
customers = data.customers ?? [];
|
||||
xero = data.xero ?? null;
|
||||
});
|
||||
|
||||
const STATUSES = [
|
||||
'submitted',
|
||||
'under_review',
|
||||
'confirmed',
|
||||
'sent_to_xero',
|
||||
'in_production',
|
||||
'ready_for_pickup',
|
||||
'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 statusChoice = $state('');
|
||||
|
||||
@@ -65,6 +22,10 @@
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load order.');
|
||||
}
|
||||
}
|
||||
function closeOrder() {
|
||||
selectedOrder = null;
|
||||
statusChoice = '';
|
||||
}
|
||||
async function refreshOrders() {
|
||||
try {
|
||||
orders = await api.orderingAdmin.orders();
|
||||
@@ -112,214 +73,23 @@
|
||||
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>
|
||||
|
||||
<div class="admin-ordering">
|
||||
<header>
|
||||
<p class="eyebrow">Ordering</p>
|
||||
<h1>Order management</h1>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class:active={tab === 'orders'} onclick={() => (tab = 'orders')}>Orders</button>
|
||||
<button class:active={tab === 'products'} onclick={() => (tab = 'products')}>Products</button>
|
||||
<button class:active={tab === 'customers'} onclick={() => (tab = 'customers')}>Customers & Pricing</button>
|
||||
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings & Xero</button>
|
||||
</nav>
|
||||
|
||||
{#if tab === 'orders'}
|
||||
<div class="split">
|
||||
<section class="surface-card">
|
||||
<h2>Order queue</h2>
|
||||
<div class="card-head">
|
||||
<h2>Orders ({orders.length})</h2>
|
||||
</div>
|
||||
{#if !orders.length}
|
||||
<p class="empty">No submitted orders.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<table class="clickable">
|
||||
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Subtotal</th><th>Xero</th></tr></thead>
|
||||
<tbody>
|
||||
{#each orders as o (o.id)}
|
||||
<tr class:selected={selectedOrder?.id === o.id} onclick={() => openOrder(o)}>
|
||||
<td>{o.order_number ?? `#${o.id}`}</td>
|
||||
<td>{o.customer_name}</td>
|
||||
<td><span class="pill">{label(o.status)}</span></td>
|
||||
<td><span class="pill {statusTone(o.status)}">{label(o.status)}</span></td>
|
||||
<td>{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
|
||||
<td>{o.xero_status ?? '—'}</td>
|
||||
</tr>
|
||||
@@ -330,7 +100,16 @@
|
||||
</section>
|
||||
|
||||
{#if selectedOrder}
|
||||
<section class="surface-card detail">
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeOrder}>
|
||||
<div
|
||||
class="modal wide detail"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Order detail"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') closeOrder(); }}
|
||||
>
|
||||
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
|
||||
<p class="muted">{selectedOrder.customer_name} · {label(selectedOrder.status)} · PO {selectedOrder.purchase_order_number ?? '—'}</p>
|
||||
<table class="lines">
|
||||
@@ -355,11 +134,12 @@
|
||||
<div class="actions">
|
||||
<select bind:value={statusChoice}>
|
||||
<option value="">Change status…</option>
|
||||
{#each STATUSES as s}<option value={s}>{label(s)}</option>{/each}
|
||||
{#each ORDER_STATUSES as s}<option value={s}>{label(s)}</option>{/each}
|
||||
</select>
|
||||
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
|
||||
<button class="secondary" onclick={sendToXero}>Send to Xero</button>
|
||||
<button class="secondary" onclick={reopenOrder}>Reopen</button>
|
||||
<button class="secondary" onclick={closeOrder}>Close</button>
|
||||
</div>
|
||||
|
||||
{#if selectedOrder.status_history?.length}
|
||||
@@ -372,220 +152,6 @@
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'products'}
|
||||
<section class="surface-card">
|
||||
<h2>New product</h2>
|
||||
<div class="form-grid">
|
||||
<label>Name<input bind:value={newProduct.name} /></label>
|
||||
<label>SKU<input bind:value={newProduct.sku} /></label>
|
||||
<label>Category
|
||||
<select bind:value={newProduct.category}>{#each 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"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label>
|
||||
<button class="primary" onclick={createProduct}>Create product</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-card">
|
||||
<h2>Catalogue ({products.length})</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>SKU</th><th>Category</th><th>Base price</th><th>Active</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each products as p (p.id)}
|
||||
<tr>
|
||||
<td>{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</td>
|
||||
<td>{p.sku}</td>
|
||||
<td>{label(p.category)}</td>
|
||||
<td><input class="inline" type="number" step="0.01" value={p.base_price ?? ''} onchange={(e) => saveProductPrice(p, e.currentTarget.value)} /></td>
|
||||
<td>{p.active ? 'Yes' : 'No'}</td>
|
||||
<td><button class="link" onclick={() => toggleProductActive(p)}>{p.active ? 'Disable' : 'Enable'}</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'customers'}
|
||||
<div class="split">
|
||||
<section class="surface-card">
|
||||
<h2>New customer</h2>
|
||||
<div class="form-row">
|
||||
<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>
|
||||
<label class="check"><input type="checkbox" checked={row.visible} onchange={() => toggleVisibility(row)} /> {row.name}</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'settings'}
|
||||
<div class="split">
|
||||
<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">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>
|
||||
|
||||
<style>
|
||||
.admin-ordering { display: grid; gap: 1rem; }
|
||||
h1 { margin: 0.15rem 0; font-size: 1.4rem; }
|
||||
h2 { margin: 0 0 0.7rem; font-size: 1.05rem; }
|
||||
h3 { margin: 0 0 0.4rem; font-size: 0.92rem; }
|
||||
.mt { margin-top: 1rem; }
|
||||
.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; }
|
||||
.tabs { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.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; }
|
||||
.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; }
|
||||
th, td { text-align: left; padding: 0.45rem 0.55rem; border-bottom: 1px solid rgba(34,54,45,0.08); }
|
||||
th { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: #7c8c82; }
|
||||
tbody tr { cursor: pointer; }
|
||||
tbody tr.selected { background: rgba(47,111,79,0.08); }
|
||||
.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; }
|
||||
.empty { color: #7c8c82; font-size: 0.85rem; }
|
||||
.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>
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||
|
||||
const EMPTY = { orders: [], products: [], customers: [], xero: null } as const;
|
||||
import type { Order } from '$lib/types';
|
||||
|
||||
// Orders queue. Access is already enforced by the family +layout.ts guard.
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return { ...EMPTY };
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
if (!canManageOrdering(session)) {
|
||||
// Customers (or anyone without manage rights) don't belong here.
|
||||
throw redirect(307, getWorkspaceHomeHref(session));
|
||||
return { orders: [] as Order[] };
|
||||
}
|
||||
|
||||
try {
|
||||
const [orders, products, customers, xero] = await Promise.all([
|
||||
api.orderingAdmin.orders(undefined, fetch),
|
||||
api.orderingAdmin.products(fetch),
|
||||
api.orderingAdmin.customers(fetch),
|
||||
api.orderingAdmin.xeroStatus(fetch)
|
||||
]);
|
||||
return { orders, products, customers, xero };
|
||||
const orders = await api.orderingAdmin.orders(undefined, fetch);
|
||||
return { orders };
|
||||
} catch {
|
||||
return { ...EMPTY };
|
||||
return { orders: [] as Order[] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import { statusTone } from '$lib/ordering/format';
|
||||
import type { CustomerVisibilityRow, OrderingCustomer, OrderingCustomerUser } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let customers = $state<OrderingCustomer[]>([]);
|
||||
$effect(() => {
|
||||
customers = data.customers ?? [];
|
||||
});
|
||||
|
||||
let newCustomer = $state({ name: '', client_code: '' });
|
||||
let showNewCustomer = $state(false);
|
||||
let newCustomerNameInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
function openNewCustomer() {
|
||||
newCustomer = { name: '', client_code: '' };
|
||||
showNewCustomer = true;
|
||||
}
|
||||
function closeNewCustomer() {
|
||||
showNewCustomer = false;
|
||||
}
|
||||
$effect(() => {
|
||||
if (showNewCustomer) tick().then(() => newCustomerNameInput?.focus());
|
||||
});
|
||||
let selectedCustomer = $state<OrderingCustomer | null>(null);
|
||||
let custUsers = $state<OrderingCustomerUser[]>([]);
|
||||
let custVisibility = $state<CustomerVisibilityRow[]>([]);
|
||||
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
|
||||
|
||||
async function refreshCustomers() {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
async function openCustomer(c: OrderingCustomer) {
|
||||
selectedCustomer = c;
|
||||
try {
|
||||
[custUsers, custVisibility] = await Promise.all([
|
||||
api.orderingAdmin.customerUsers(c.id),
|
||||
api.orderingAdmin.visibility(c.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load customer.');
|
||||
}
|
||||
}
|
||||
async function toggleCustomerStatus(c: OrderingCustomer) {
|
||||
try {
|
||||
const updated = await api.orderingAdmin.updateCustomer(c.id, { status: c.status === 'active' ? 'disabled' : 'active' });
|
||||
toast.success(`Customer ${updated.status}.`);
|
||||
await refreshCustomers();
|
||||
if (selectedCustomer?.id === c.id) selectedCustomer = updated;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function addUser() {
|
||||
if (!selectedCustomer) return;
|
||||
if (!newUser.full_name || !newUser.email) return toast.error('Name and email required.');
|
||||
try {
|
||||
await api.orderingAdmin.createCustomerUser(selectedCustomer.id, newUser);
|
||||
toast.success('User invited.');
|
||||
newUser = { full_name: '', email: '', role: 'buyer' };
|
||||
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
|
||||
await refreshCustomers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not add user.');
|
||||
}
|
||||
}
|
||||
async function toggleUserStatus(u: OrderingCustomerUser) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
const next = u.status === 'suspended' ? 'active' : 'suspended';
|
||||
await api.orderingAdmin.updateCustomerUser(selectedCustomer.id, u.id, { status: next });
|
||||
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function toggleVisibility(row: CustomerVisibilityRow) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
await api.orderingAdmin.setVisibility(selectedCustomer.id, { product_id: row.product_id, visible: !row.visible });
|
||||
custVisibility = await api.orderingAdmin.visibility(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Customers ({customers.length})</h2>
|
||||
<button class="primary" onclick={openNewCustomer}>New customer</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Code</th><th>Users</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each customers as c (c.id)}
|
||||
<tr class:selected={selectedCustomer?.id === c.id}>
|
||||
<td><button class="link" onclick={() => openCustomer(c)}>{c.name}</button></td>
|
||||
<td>{c.client_code}</td>
|
||||
<td>{c.user_count}</td>
|
||||
<td><span class="pill {statusTone(c.status)}">{c.status}</span></td>
|
||||
<td><button class="link" onclick={() => toggleCustomerStatus(c)}>{c.status === 'active' ? 'Disable' : 'Enable'}</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{#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">Product visibility</h3>
|
||||
<ul class="mini visibility">
|
||||
{#each custVisibility as row (row.product_id)}
|
||||
<li>
|
||||
<label class="check"><input type="checkbox" checked={row.visible} onchange={() => toggleVisibility(row)} /> {row.name}</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<p class="muted mt">Manage discounts and per-product pricing for this customer on the <a href="/ordering/manage/pricing">Pricing</a> page.</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if showNewCustomer}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeNewCustomer}>
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="New customer"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') closeNewCustomer(); }}
|
||||
>
|
||||
<h2>New customer</h2>
|
||||
<div class="form-grid">
|
||||
<label class="full">Company name<input bind:this={newCustomerNameInput} bind:value={newCustomer.name} /></label>
|
||||
<label class="full">Client code<input placeholder="e.g. ACME" bind:value={newCustomer.client_code} /></label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" onclick={closeNewCustomer}>Cancel</button>
|
||||
<button class="primary" onclick={createCustomer}>Create customer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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,118 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
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 ?? [];
|
||||
});
|
||||
|
||||
const blankProduct = () => ({ name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null as number | null, requires_quote: false, active: true });
|
||||
let newProduct = $state<Record<string, any>>(blankProduct());
|
||||
let showNewProduct = $state(false);
|
||||
let newProductNameInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
function openNewProduct() {
|
||||
newProduct = blankProduct();
|
||||
showNewProduct = true;
|
||||
}
|
||||
function closeNewProduct() {
|
||||
showNewProduct = false;
|
||||
}
|
||||
$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 ({products.length})</h2>
|
||||
<button class="primary" onclick={openNewProduct}>New product</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>SKU</th><th>Category</th><th>Base price</th><th>Active</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each products as p (p.id)}
|
||||
<tr>
|
||||
<td>{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</td>
|
||||
<td>{p.sku}</td>
|
||||
<td>{label(p.category)}</td>
|
||||
<td><input class="inline" type="number" step="0.01" value={p.base_price ?? ''} onchange={(e) => saveProductPrice(p, e.currentTarget.value)} /></td>
|
||||
<td>{p.active ? 'Yes' : 'No'}</td>
|
||||
<td><button class="link" onclick={() => toggleProductActive(p)}>{p.active ? 'Disable' : 'Enable'}</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{#if showNewProduct}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeNewProduct}>
|
||||
<div
|
||||
class="modal wide"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="New product"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') closeNewProduct(); }}
|
||||
>
|
||||
<h2>New product</h2>
|
||||
<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={closeNewProduct}>Cancel</button>
|
||||
<button class="primary" onclick={createProduct}>Create product</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -735,11 +735,34 @@
|
||||
--costing-line: oklch(88% 0.014 145);
|
||||
--costing-line-strong: oklch(78% 0.02 145);
|
||||
--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;
|
||||
gap: 1.05rem;
|
||||
color: var(--costing-ink);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.health-strip,
|
||||
.workspace-grid,
|
||||
@@ -809,7 +832,7 @@
|
||||
padding: 0.38rem 0.62rem;
|
||||
border-radius: 999px;
|
||||
color: var(--costing-warn);
|
||||
background: oklch(95% 0.055 82);
|
||||
background: var(--costing-warn-soft);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -881,7 +904,7 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -978,7 +1001,7 @@
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--costing-line-strong);
|
||||
border-radius: 0.66rem;
|
||||
background: oklch(99% 0.004 145);
|
||||
background: var(--costing-input-bg);
|
||||
color: var(--costing-ink);
|
||||
}
|
||||
|
||||
@@ -995,7 +1018,7 @@
|
||||
align-items: center;
|
||||
border: 1px solid var(--costing-line-strong);
|
||||
border-radius: 0.66rem;
|
||||
background: oklch(99% 0.004 145);
|
||||
background: var(--costing-input-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1085,7 +1108,7 @@
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1166,7 +1189,7 @@
|
||||
|
||||
.status-pill.warning {
|
||||
color: var(--costing-warn);
|
||||
background: oklch(95% 0.055 82);
|
||||
background: var(--costing-warn-soft);
|
||||
}
|
||||
|
||||
.skeleton-row td {
|
||||
@@ -1257,10 +1280,10 @@
|
||||
align-items: flex-start;
|
||||
padding: 0.78rem;
|
||||
margin-bottom: 0.85rem;
|
||||
border: 1px solid oklch(84% 0.085 78);
|
||||
border: 1px solid var(--costing-warn-border);
|
||||
border-radius: 0.82rem;
|
||||
color: var(--costing-warn);
|
||||
background: oklch(96% 0.045 84);
|
||||
background: var(--costing-warn-box);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -617,7 +617,7 @@
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #7f8e85;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -680,14 +680,14 @@
|
||||
|
||||
.feedback.success {
|
||||
color: var(--green-deep);
|
||||
border-color: #d8ecdf;
|
||||
background: #f6fcf8;
|
||||
border-color: color-mix(in srgb, var(--color-success) 22%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-success-tint) 55%, var(--color-bg-surface));
|
||||
}
|
||||
|
||||
.feedback.error {
|
||||
color: #a03737;
|
||||
border-color: #f0d9d9;
|
||||
background: #fff8f8;
|
||||
color: var(--color-error);
|
||||
border-color: color-mix(in srgb, var(--color-error) 22%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-error) 8%, var(--color-bg-surface));
|
||||
}
|
||||
|
||||
.metric-row,
|
||||
@@ -839,7 +839,7 @@
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: #53645b;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -860,7 +860,7 @@
|
||||
padding: 0.85rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.9rem;
|
||||
color: #fff;
|
||||
color: var(--color-on-brand);
|
||||
background: var(--color-brand);
|
||||
box-shadow: none;
|
||||
font-weight: 600;
|
||||
@@ -955,13 +955,13 @@
|
||||
}
|
||||
|
||||
.material-icon.active {
|
||||
color: #fff;
|
||||
color: var(--color-on-brand);
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.material-icon.muted {
|
||||
color: #55685f;
|
||||
background: #e9efeb;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
@@ -981,8 +981,8 @@
|
||||
}
|
||||
|
||||
.status-pill.neutral {
|
||||
color: #5a6c63;
|
||||
background: #edf2ef;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.material-grid {
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
|
||||
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
||||
import { clientSession } from '$lib/session';
|
||||
import { canEditThroughput } from '$lib/workspace-access';
|
||||
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');
|
||||
|
||||
// Only operators who can edit throughput see (and can use) the import tool.
|
||||
const canImportThroughput = $derived(canEditThroughput($clientSession));
|
||||
|
||||
let name = $state($clientSession?.name ?? '');
|
||||
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(
|
||||
($clientSession?.name ?? '')
|
||||
.split(' ')
|
||||
@@ -78,12 +167,13 @@
|
||||
.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: 'security', label: 'Security', icon: LockKeyhole },
|
||||
];
|
||||
...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
|
||||
]);
|
||||
|
||||
const railGroups = [{ items: navItems }];
|
||||
const railGroups = $derived([{ items: navItems }]);
|
||||
</script>
|
||||
|
||||
<AppSecondaryRailLayout>
|
||||
@@ -164,6 +254,92 @@
|
||||
</div>
|
||||
</form>
|
||||
</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}
|
||||
</div>
|
||||
</AppSecondaryRailLayout>
|
||||
@@ -288,11 +464,173 @@
|
||||
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 ─────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.import-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</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 }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return { entries: [], products: [] };
|
||||
return { entries: [], statsEntries: [], products: [] };
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
@@ -18,13 +18,22 @@ export async function load({ fetch }) {
|
||||
recentFrom.setDate(recentFrom.getDate() - 30);
|
||||
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 {
|
||||
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: statsDateFrom, limit: 1000 }, fetch),
|
||||
api.throughputProducts(fetch)
|
||||
]);
|
||||
return { entries, products };
|
||||
return { entries, statsEntries, products };
|
||||
} catch {
|
||||
return { entries: [], products: [] };
|
||||
return { entries: [], statsEntries: [], products: [] };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user