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:
2026-06-13 10:01:10 +12:00
co-authored by Claude Opus 4.8
parent 4ff372d307
commit 2de82776cb
64 changed files with 6034 additions and 1134 deletions
+320 -11
View File
@@ -1,14 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select from sqlalchemy import case, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload, selectinload from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession, get_auth_session from app.api.deps import AuthSession, get_auth_session
from app.db.session import get_db from app.db.session import get_db
from app.models.mix import Mix from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial from app.models.raw_material import RawMaterial
from app.schemas.editor import ( from app.schemas.editor import (
EditorIngredientCreate,
EditorIngredientRow,
EditorIngredientUpdate,
EditorMixFormulaRead,
EditorMixIngredientCreate,
EditorMixIngredientUpdate,
EditorMixRow,
EditorMixUpdate, EditorMixUpdate,
EditorProductFormulaRead, EditorProductFormulaRead,
EditorProductIngredientCreate, EditorProductIngredientCreate,
@@ -17,6 +24,7 @@ from app.schemas.editor import (
EditorProductUpdate, EditorProductUpdate,
) )
from app.services.client_access_service import has_access_level from app.services.client_access_service import has_access_level
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
router = APIRouter(prefix="/api/editor", tags=["editor"]) 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: def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None:
return db.scalar( return db.scalar(
select(Product) select(Product)
@@ -156,7 +223,34 @@ def update_editor_product(
return _serialize_row(product) return _serialize_row(product)
@router.patch("/mixes/{mix_id}", response_model=list[EditorProductRow]) @router.get("/mixes", response_model=list[EditorMixRow])
def list_editor_mixes(
q: str | None = Query(default=None, max_length=255),
client_name: str | None = Query(default=None, max_length=255),
limit: int = Query(default=500, ge=1, le=1000),
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
statement = select(Mix).where(Mix.tenant_id == session.tenant_id)
if client_name:
statement = statement.where(Mix.client_name == client_name)
if q:
term = f"%{q.strip()}%"
statement = statement.where(or_(Mix.client_name.ilike(term), Mix.name.ilike(term)))
statement = statement.order_by(Mix.client_name, Mix.name, Mix.id).limit(limit)
counts = _mix_product_counts(db, session.tenant_id or "")
mixes = db.scalars(statement).all()
return [
_serialize_mix_row(mix, visible_count=counts.get(mix.id, (0, 0))[1], product_count=counts.get(mix.id, (0, 0))[0])
for mix in mixes
]
@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
def update_editor_mix( def update_editor_mix(
mix_id: int, mix_id: int,
payload: EditorMixUpdate, payload: EditorMixUpdate,
@@ -167,18 +261,120 @@ def update_editor_mix(
if mix is None: if mix is None:
raise HTTPException(status_code=404, detail="Mix not found") raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items(): updates = payload.model_dump(exclude_unset=True)
# `visible` is a virtual field: it fans out to the visibility of every product
# under the mix rather than mapping to a mix column.
visible = updates.pop("visible", None)
for field, value in updates.items():
setattr(mix, field, value) setattr(mix, field, value)
if visible is not None:
for product in db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
).all():
product.visible = visible
db.commit() db.commit()
products = db.scalars( counts = _mix_product_counts(db, session.tenant_id or "")
select(Product) total, visible_count = counts.get(mix_id, (0, 0))
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id) return _serialize_mix_row(mix, visible_count=visible_count, product_count=total)
.options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name, Product.id)
).all() @router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead)
return [_serialize_row(product) for product in products] def get_editor_mix_ingredients(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
return _serialize_mix_formula(mix)
@router.post("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead, status_code=201)
def add_editor_mix_ingredient(
mix_id: int,
payload: EditorMixIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Raw material not found")
db.add(
MixIngredient(
tenant_id=session.tenant_id or "",
mix_id=mix_id,
raw_material_id=payload.raw_material_id,
quantity_kg=payload.quantity_kg,
notes=payload.notes,
)
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=400, detail="Raw material is already on this mix") from exc
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
return _serialize_mix_formula(mix)
@router.patch("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
def update_editor_mix_ingredient(
mix_id: int,
ingredient_id: int,
payload: EditorMixIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(MixIngredient)
.join(Mix)
.where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
Mix.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(ingredient, field, value)
db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
return _serialize_mix_formula(mix)
@router.delete("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
def delete_editor_mix_ingredient(
mix_id: int,
ingredient_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(MixIngredient)
.join(Mix)
.where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
Mix.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
db.delete(ingredient)
db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
return _serialize_mix_formula(mix)
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead) @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 "") product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product) return _serialize_product_formula(product)
# --- Ingredients (raw materials) catalogue -----------------------------------
#
# The mix editor consumes raw materials as the ingredients dropdown; this gives
# Lean admins a sibling editor to curate that catalogue — the ingredients that
# ultimately get used inside mixes. Same auth/tenant model as the mix editor.
def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict:
active_price = get_active_price(material)
cost_per_kg = (
calculate_raw_material_cost(material, active_price).cost_per_kg if active_price is not None else None
)
return {
"id": material.id,
"name": material.name,
"supplier": material.supplier,
"unit_of_measure": material.unit_of_measure,
"kg_per_unit": material.kg_per_unit,
"status": material.status,
"rounding_decimals": material.rounding_decimals,
"notes": material.notes,
"cost_per_kg": cost_per_kg,
"usage_count": usage_count,
"created_at": material.created_at,
}
def _ingredient_usage_counts(db: Session, tenant_id: str) -> dict[int, int]:
"""How many product/mix formula rows reference each raw material."""
rows = db.execute(
select(ProductIngredient.raw_material_id, func.count())
.where(ProductIngredient.tenant_id == tenant_id)
.group_by(ProductIngredient.raw_material_id)
).all()
return {raw_material_id: count for raw_material_id, count in rows}
@router.get("/ingredients", response_model=list[EditorIngredientRow])
def list_editor_ingredients(
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == tenant_id)
.options(selectinload(RawMaterial.price_versions))
.order_by(RawMaterial.name)
).all()
usage = _ingredient_usage_counts(db, tenant_id)
return [_serialize_ingredient(material, usage.get(material.id, 0)) for material in materials]
@router.post("/ingredients", response_model=EditorIngredientRow, status_code=201)
def create_editor_ingredient(
payload: EditorIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
material = RawMaterial(
tenant_id=session.tenant_id or "",
name=payload.name.strip(),
supplier=(payload.supplier or "").strip() or None,
unit_of_measure=payload.unit_of_measure.strip(),
kg_per_unit=payload.kg_per_unit,
status=payload.status.strip() or "active",
rounding_decimals=payload.rounding_decimals,
notes=payload.notes,
)
db.add(material)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
db.refresh(material)
return _serialize_ingredient(material, 0)
@router.patch("/ingredients/{ingredient_id}", response_model=EditorIngredientRow)
def update_editor_ingredient(
ingredient_id: int,
payload: EditorIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
material = db.scalar(
select(RawMaterial)
.where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)
.options(selectinload(RawMaterial.price_versions))
)
if material is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
updates = payload.model_dump(exclude_unset=True)
if "name" in updates and updates["name"] is not None:
updates["name"] = updates["name"].strip()
if "supplier" in updates:
updates["supplier"] = (updates["supplier"] or "").strip() or None
if "unit_of_measure" in updates and updates["unit_of_measure"] is not None:
updates["unit_of_measure"] = updates["unit_of_measure"].strip()
for field, value in updates.items():
setattr(material, field, value)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
db.refresh(material)
usage = _ingredient_usage_counts(db, tenant_id)
return _serialize_ingredient(material, usage.get(material.id, 0))
+162 -3
View File
@@ -7,9 +7,10 @@ within the seller's ordering tenant.
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
@@ -27,6 +28,7 @@ from app.models.ordering import (
PriceListItem, PriceListItem,
PriceTier, PriceTier,
ProductCategory, ProductCategory,
XeroContactLink,
XeroSyncLog, XeroSyncLog,
) )
from app.schemas.ordering import ( from app.schemas.ordering import (
@@ -47,6 +49,7 @@ from app.schemas.ordering import (
PriceListItemUpsert, PriceListItemUpsert,
ReopenOrderRequest, ReopenOrderRequest,
VisibilityUpdate, VisibilityUpdate,
XeroContactLinkUpsert,
) )
from app.services import ordering_service as svc from app.services import ordering_service as svc
from app.services.client_access_service import ( from app.services.client_access_service import (
@@ -54,7 +57,11 @@ from app.services.client_access_service import (
record_audit_event, record_audit_event,
) )
from app.services.order_notifications import get_or_create_settings from app.services.order_notifications import get_or_create_settings
from app.services.xero_service import submit_order_to_xero, xero_status_snapshot from app.services.xero_service import (
list_xero_contacts,
submit_order_to_xero,
xero_status_snapshot,
)
router = APIRouter(prefix="/api/ordering-admin", tags=["ordering-admin"]) router = APIRouter(prefix="/api/ordering-admin", tags=["ordering-admin"])
@@ -122,11 +129,21 @@ def _serialize_tiers(db: Session, *, customer_product_price_id=None, price_list_
# --- Customers --------------------------------------------------------------- # --- Customers ---------------------------------------------------------------
def _xero_link_for(db: Session, customer_id: int) -> XeroContactLink | None:
return db.scalar(
select(XeroContactLink).where(
XeroContactLink.tenant_id == TENANT,
XeroContactLink.client_account_id == customer_id,
)
)
def _serialize_customer(db: Session, account: ClientAccount) -> dict: def _serialize_customer(db: Session, account: ClientAccount) -> dict:
users = db.scalars(select(ClientUser).where(ClientUser.client_account_id == account.id)).all() users = db.scalars(select(ClientUser).where(ClientUser.client_account_id == account.id)).all()
assignment = db.scalar( assignment = db.scalar(
select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == account.id) select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == account.id)
) )
link = _xero_link_for(db, account.id)
return { return {
"id": account.id, "id": account.id,
"name": account.name, "name": account.name,
@@ -137,6 +154,8 @@ def _serialize_customer(db: Session, account: ClientAccount) -> dict:
"user_count": len(users), "user_count": len(users),
"price_list_id": assignment.price_list_id if assignment else None, "price_list_id": assignment.price_list_id if assignment else None,
"discount_percent": assignment.discount_percent if assignment else 0.0, "discount_percent": assignment.discount_percent if assignment else 0.0,
"xero_contact_id": link.xero_contact_id if link else None,
"xero_contact_name": link.xero_contact_name if link else None,
"created_at": account.created_at, "created_at": account.created_at,
} }
@@ -905,7 +924,10 @@ def send_order_to_xero(
if order.status not in {"confirmed", "in_production", "sent_to_xero"}: if order.status not in {"confirmed", "in_production", "sent_to_xero"}:
raise HTTPException(status_code=409, detail="Only confirmed orders can be sent to Xero") raise HTTPException(status_code=409, detail="Only confirmed orders can be sent to Xero")
account = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id)) account = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
result = submit_order_to_xero(order, account) link = _xero_link_for(db, order.client_account_id)
result = submit_order_to_xero(order, account, link)
if link is not None and result.status == "success":
link.last_synced_at = datetime.utcnow()
db.add( db.add(
XeroSyncLog( XeroSyncLog(
@@ -985,8 +1007,19 @@ def get_xero_status(
recent = db.scalars( recent = db.scalars(
select(XeroSyncLog).where(XeroSyncLog.tenant_id == TENANT).order_by(XeroSyncLog.created_at.desc()).limit(20) select(XeroSyncLog).where(XeroSyncLog.tenant_id == TENANT).order_by(XeroSyncLog.created_at.desc()).limit(20)
).all() ).all()
total_customers = db.scalar(
select(func.count()).select_from(ClientAccount)
) or 0
linked_customers = db.scalar(
select(func.count()).select_from(XeroContactLink).where(XeroContactLink.tenant_id == TENANT)
) or 0
return { return {
"connection": xero_status_snapshot(), "connection": xero_status_snapshot(),
"contact_links": {
"linked": linked_customers,
"total": total_customers,
"unlinked": max(total_customers - linked_customers, 0),
},
"recent_syncs": [ "recent_syncs": [
{ {
"id": log.id, "id": log.id,
@@ -999,3 +1032,129 @@ def get_xero_status(
for log in recent for log in recent
], ],
} }
# --- Xero contact mapping ----------------------------------------------------
@router.get("/xero/contacts")
def list_available_xero_contacts(
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
"""The Xero contacts available to link a customer against (stub or live)."""
contacts, stubbed = list_xero_contacts()
return {"contacts": [c.as_dict() for c in contacts], "stubbed": stubbed}
@router.get("/xero/contact-links")
def list_xero_contact_links(
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
"""Every customer with its current Xero link and a suggested match.
The suggestion is a best-effort name match against the available contacts so
the operator can confirm rather than hunt through a dropdown.
"""
contacts, _ = list_xero_contacts()
by_name = {c.name.strip().lower(): c for c in contacts}
accounts = db.scalars(select(ClientAccount).order_by(ClientAccount.name)).all()
rows = []
for account in accounts:
link = _xero_link_for(db, account.id)
suggestion = None if link else by_name.get(account.name.strip().lower())
rows.append(
{
"customer_id": account.id,
"customer_name": account.name,
"client_code": account.client_code,
"linked": link is not None,
"xero_contact_id": link.xero_contact_id if link else None,
"xero_contact_name": link.xero_contact_name if link else None,
"xero_contact_email": link.xero_contact_email if link else None,
"last_synced_at": link.last_synced_at if link else None,
"suggested_contact_id": suggestion.contact_id if suggestion else None,
}
)
return rows
@router.put("/customers/{customer_id}/xero-link")
def link_customer_to_xero(
customer_id: int,
payload: XeroContactLinkUpsert,
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
account = _customer_or_404(db, customer_id)
# Default the cached name/email from the known contact list when the caller
# only sends an id, so the mapping reads nicely without a live round-trip.
name = payload.xero_contact_name
email = payload.xero_contact_email
if name is None or email is None:
contacts, _ = list_xero_contacts()
match = next((c for c in contacts if c.contact_id == payload.xero_contact_id), None)
if match is not None:
name = name or match.name
email = email or match.email
link = _xero_link_for(db, customer_id)
if link is None:
link = XeroContactLink(
tenant_id=TENANT,
client_account_id=customer_id,
xero_contact_id=payload.xero_contact_id,
xero_contact_name=name,
xero_contact_email=email,
)
db.add(link)
else:
link.xero_contact_id = payload.xero_contact_id
link.xero_contact_name = name
link.xero_contact_email = email
record_audit_event(
db,
tenant_id=account.tenant_id,
client_account_id=account.id,
action="xero.contact_linked",
target_type="xero_contact_link",
target_id=account.id,
module_key="ordering",
summary=f"{account.name} linked to Xero contact {name or payload.xero_contact_id}.",
**_actor(session),
)
db.commit()
return {
"customer_id": customer_id,
"linked": True,
"xero_contact_id": link.xero_contact_id,
"xero_contact_name": link.xero_contact_name,
"xero_contact_email": link.xero_contact_email,
}
@router.delete("/customers/{customer_id}/xero-link", status_code=status.HTTP_204_NO_CONTENT)
def unlink_customer_from_xero(
customer_id: int,
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
account = _customer_or_404(db, customer_id)
link = _xero_link_for(db, customer_id)
if link is not None:
db.delete(link)
record_audit_event(
db,
tenant_id=account.tenant_id,
client_account_id=account.id,
action="xero.contact_unlinked",
target_type="xero_contact_link",
target_id=account.id,
module_key="ordering",
summary=f"{account.name} unlinked from Xero contact.",
**_actor(session),
)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+37 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -13,16 +13,21 @@ from app.schemas.throughput import (
ThroughputEntryCreate, ThroughputEntryCreate,
ThroughputEntryRead, ThroughputEntryRead,
ThroughputEntryUpdate, ThroughputEntryUpdate,
ThroughputImportResult,
ThroughputProductCreate, ThroughputProductCreate,
ThroughputProductRead, ThroughputProductRead,
ThroughputProductUpdate, ThroughputProductUpdate,
) )
from app.services.throughput_service import ( from app.services.throughput_service import (
calculate_kg, calculate_kg,
import_entries_from_file,
normalise_staff_name, normalise_staff_name,
serialize_entry, serialize_entry,
) )
# Uploaded files larger than this are rejected before we read them into memory.
_MAX_IMPORT_BYTES = 10 * 1024 * 1024 # 10 MB
router = APIRouter(prefix="/api/throughput", tags=["operations-throughput"]) router = APIRouter(prefix="/api/throughput", tags=["operations-throughput"])
MODULE_KEY = "operations_throughput" MODULE_KEY = "operations_throughput"
@@ -184,6 +189,33 @@ def create_entry(
return serialize_entry(entry) return serialize_entry(entry)
@router.post("/import", response_model=ThroughputImportResult)
def import_entries(
file: UploadFile = File(...),
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
content = file.file.read()
if not content:
raise HTTPException(status_code=400, detail="The uploaded file is empty.")
if len(content) > _MAX_IMPORT_BYTES:
raise HTTPException(status_code=413, detail="File is too large. Keep uploads under 10 MB.")
try:
result = import_entries_from_file(
db,
filename=file.filename or "upload.csv",
content=content,
tenant_id=session.tenant_id,
created_by=session.email,
)
except ValueError as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
return result
@router.get("/entries/{entry_id}", response_model=ThroughputEntryRead) @router.get("/entries/{entry_id}", response_model=ThroughputEntryRead)
def get_entry( def get_entry(
entry_id: int, entry_id: int,
@@ -233,7 +265,10 @@ def update_entry(
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_entry( def delete_entry(
entry_id: int, entry_id: int,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "manage")), # Correcting a mistaken run is part of day-to-day operating, so deleting an
# entry sits at the same "edit" level as adding/editing one. (No throughput
# role is granted "manage", so requiring it here would 403 everyone.)
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
entry = db.scalar( entry = db.scalar(
+2
View File
@@ -129,6 +129,8 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
("production_throughput_entries", "for_stock", "BOOLEAN NOT NULL DEFAULT FALSE"), ("production_throughput_entries", "for_stock", "BOOLEAN NOT NULL DEFAULT FALSE"),
("production_throughput_entries", "job_number", "VARCHAR(64)"), ("production_throughput_entries", "job_number", "VARCHAR(64)"),
("production_throughput_entries", "stock_quantity", "FLOAT"), ("production_throughput_entries", "stock_quantity", "FLOAT"),
("raw_materials", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
("mix_calculator_session_lines", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
) )
+2
View File
@@ -52,6 +52,8 @@ class MixCalculatorSessionLine(Base):
required_kg: Mapped[float] = mapped_column(Float) required_kg: Mapped[float] = mapped_column(Float)
mix_percentage: Mapped[float] = mapped_column(Float) mix_percentage: Mapped[float] = mapped_column(Float)
unit: Mapped[str] = mapped_column(String(64)) unit: Mapped[str] = mapped_column(String(64))
# Snapshot of the ingredient's rounding setting at save time.
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
sort_order: Mapped[int] = mapped_column(Integer, default=0) sort_order: Mapped[int] = mapped_column(Integer, default=0)
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines") session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
+28
View File
@@ -376,6 +376,34 @@ class NotificationSetting(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class XeroContactLink(Base):
"""Persistent link between a customer (:class:`ClientAccount`) and a Xero
contact.
Maintained from the Integrations console. Once a customer is linked, order
invoices reference the real Xero ``ContactID`` instead of falling back to the
client code which is what lets Xero attach the invoice to the right
contact rather than creating a duplicate. One link per customer per tenant.
"""
__tablename__ = "xero_contact_links"
__table_args__ = (
UniqueConstraint("tenant_id", "client_account_id", name="uq_xero_contact_link_customer"),
)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
xero_contact_id: Mapped[str] = mapped_column(String(128))
xero_contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
xero_contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# When the contact details were last reconciled with Xero (a future live
# sync can refresh name/email and stamp this).
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class XeroSyncLog(Base): class XeroSyncLog(Base):
__tablename__ = "xero_sync_log" __tablename__ = "xero_sync_log"
+4 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base from app.db.session import Base
@@ -18,6 +18,9 @@ class RawMaterial(Base):
unit_of_measure: Mapped[str] = mapped_column(String(64)) unit_of_measure: Mapped[str] = mapped_column(String(64))
kg_per_unit: Mapped[float] = mapped_column(Float) kg_per_unit: Mapped[float] = mapped_column(Float)
status: Mapped[str] = mapped_column(String(32), default="active") status: Mapped[str] = mapped_column(String(32), default="active")
# Decimal places this ingredient's required-kg is rounded to in the mix
# calculator output. Set per-ingredient from the Ingredients Editor.
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
notes: Mapped[str | None] = mapped_column(Text, nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+91
View File
@@ -1,3 +1,5 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field 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) client_name: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255) name: str | None = Field(default=None, min_length=1, max_length=255)
notes: str | None = Field(default=None, max_length=2000) notes: str | None = Field(default=None, max_length=2000)
# Toggling a mix's status fans out to the visibility of every product under it.
visible: bool | None = None
class EditorMixRow(BaseModel):
id: int
tenant_id: str
client_name: str
name: str
# A mix reads as "Active" when at least one of its products is visible.
visible: bool
product_count: int
visible_product_count: int
notes: str | None
class EditorMixIngredientRead(BaseModel):
id: int
raw_material_id: int
raw_material_name: str
quantity_kg: float
notes: str | None
class EditorMixFormulaRead(BaseModel):
id: int
tenant_id: str
client_name: str
name: str
ingredients: list[EditorMixIngredientRead]
total_kg: float
class EditorMixIngredientCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
raw_material_id: int
quantity_kg: float = Field(gt=0)
notes: str | None = Field(default=None, max_length=1000)
class EditorMixIngredientUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
quantity_kg: float | None = Field(default=None, gt=0)
notes: str | None = Field(default=None, max_length=1000)
class EditorProductIngredientCreate(BaseModel): class EditorProductIngredientCreate(BaseModel):
@@ -71,3 +119,46 @@ class EditorProductFormulaRead(BaseModel):
mix_name: str mix_name: str
ingredients: list[EditorProductIngredientRead] ingredients: list[EditorProductIngredientRead]
total_kg: float total_kg: float
# --- Ingredients (raw materials) catalogue -----------------------------------
class EditorIngredientRow(BaseModel):
id: int
name: str
supplier: str | None
unit_of_measure: str
kg_per_unit: float
status: str
# Decimal places this ingredient is rounded to in the mix calculator output.
rounding_decimals: int
notes: str | None
cost_per_kg: float | None
# How many product/mix formulas currently reference this ingredient.
usage_count: int
created_at: datetime
class EditorIngredientCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
supplier: str | None = Field(default=None, max_length=255)
unit_of_measure: str = Field(min_length=1, max_length=64)
kg_per_unit: float = Field(gt=0)
status: str = Field(default="active", max_length=32)
rounding_decimals: int = Field(default=2, ge=0, le=6)
notes: str | None = Field(default=None, max_length=2000)
class EditorIngredientUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=255)
supplier: str | None = Field(default=None, max_length=255)
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
kg_per_unit: float | None = Field(default=None, gt=0)
status: str | None = Field(default=None, max_length=32)
rounding_decimals: int | None = Field(default=None, ge=0, le=6)
notes: str | None = Field(default=None, max_length=2000)
+1
View File
@@ -26,6 +26,7 @@ class MixCalculatorSessionLineRead(BaseModel):
required_kg: float required_kg: float
mix_percentage: float mix_percentage: float
unit: str unit: str
rounding_decimals: int = 2
sort_order: int sort_order: int
+12
View File
@@ -238,6 +238,18 @@ class NotificationSettingsUpdate(BaseModel):
from_email: str | None = None from_email: str | None = None
# --- Admin: Xero integration -------------------------------------------------
class XeroContactLinkUpsert(BaseModel):
"""Link a customer to a Xero contact. ``xero_contact_id`` is the Xero
``ContactID`` (or, in stub mode, the deterministic stub id)."""
xero_contact_id: str = Field(min_length=1, max_length=128)
xero_contact_name: str | None = Field(default=None, max_length=255)
xero_contact_email: str | None = Field(default=None, max_length=255)
# --- Admin: customers & users ------------------------------------------------ # --- Admin: customers & users ------------------------------------------------
_CUSTOMER_STATUSES = {"active", "disabled"} _CUSTOMER_STATUSES = {"active", "disabled"}
+7
View File
@@ -117,6 +117,13 @@ class ThroughputEntryUpdate(BaseModel):
notes: str | None = Field(default=None, max_length=2000) notes: str | None = Field(default=None, max_length=2000)
class ThroughputImportResult(BaseModel):
entries_imported: int
entries_skipped: int
products_created: int
errors: list[str] = Field(default_factory=list)
class ThroughputEntryRead(BaseModel): class ThroughputEntryRead(BaseModel):
id: int id: int
tenant_id: str tenant_id: str
+4 -1
View File
@@ -319,7 +319,10 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210), fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
) )
pdf.setFont("Helvetica", table_font_size) pdf.setFont("Helvetica", table_font_size)
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}kg") # Each ingredient carries its own rounding (set in the Ingredients Editor)
# so the printed sheet matches the on-screen calculated output.
line_decimals = getattr(line, "rounding_decimals", 2)
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg, line_decimals)}kg")
strip_y = table_bottom - 6 strip_y = table_bottom - 6
if note_lines: if note_lines:
@@ -43,6 +43,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
"raw_material_name": ingredient.raw_material.name, "raw_material_name": ingredient.raw_material.name,
"quantity_kg": ingredient.quantity_kg, "quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure, "unit": ingredient.raw_material.unit_of_measure,
"rounding_decimals": ingredient.raw_material.rounding_decimals,
"sort_order": ingredient.sort_order, "sort_order": ingredient.sort_order,
} }
for ingredient in product.ingredients for ingredient in product.ingredients
@@ -55,6 +56,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}", "raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg, "quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg", "unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
"sort_order": index, "sort_order": index,
} }
for index, ingredient in enumerate(product.mix.ingredients, start=1) for index, ingredient in enumerate(product.mix.ingredients, start=1)
@@ -128,6 +130,7 @@ def calculate_mix_calculator_preview(
"required_kg": required_kg, "required_kg": required_kg,
"mix_percentage": mix_percentage, "mix_percentage": mix_percentage,
"unit": ingredient["unit"], "unit": ingredient["unit"],
"rounding_decimals": ingredient.get("rounding_decimals", 2),
"sort_order": ingredient["sort_order"] or index, "sort_order": ingredient["sort_order"] or index,
} }
) )
@@ -260,6 +263,7 @@ def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_
"required_kg": round(line.required_kg, 4), "required_kg": round(line.required_kg, 4),
"mix_percentage": round(line.mix_percentage, 4), "mix_percentage": round(line.mix_percentage, 4),
"unit": line.unit, "unit": line.unit,
"rounding_decimals": line.rounding_decimals,
"sort_order": line.sort_order, "sort_order": line.sort_order,
} }
for line in session_record.lines for line in session_record.lines
@@ -331,6 +335,7 @@ def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, pay
required_kg=line["required_kg"], required_kg=line["required_kg"],
mix_percentage=line["mix_percentage"], mix_percentage=line["mix_percentage"],
unit=line["unit"], unit=line["unit"],
rounding_decimals=line.get("rounding_decimals", 2),
sort_order=line["sort_order"], sort_order=line["sort_order"],
) )
for line in preview["lines"] for line in preview["lines"]
+309
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import csv
import io
import logging import logging
import os import os
from datetime import date, datetime from datetime import date, datetime
@@ -369,3 +371,310 @@ def resolve_workbook_path() -> Path | None:
if candidate.exists(): if candidate.exists():
return candidate return candidate
return None return None
# ── Ad-hoc CSV / spreadsheet upload import ──────────────────────────────────
# Lets an operator upload their own CSV or .xlsx of packing runs (from Settings
# → Import) and have every row saved as a throughput entry. Unlike the bundled
# workbook seed above, this is column-header driven so the file can be a simple
# hand-built sheet rather than the exact "Operations Throughput.xlsx" layout.
# Maps the column headers we accept (normalised: lower-cased, spaces/dashes →
# single spaces) onto the canonical field used internally. Several aliases per
# field so a human-built sheet "just works".
_HEADER_ALIASES: dict[str, str] = {
"date": "date",
"production date": "date",
"production_date": "date",
"product": "product",
"product name": "product",
"product_name": "product",
"product name snapshot": "product",
"name": "product",
"item id": "item_id",
"item_id": "item_id",
"itemid": "item_id",
"sku": "item_id",
"quantity": "quantity",
"qty": "quantity",
"packed": "quantity",
"quantity packed": "quantity",
"amount": "quantity",
"quantity type": "quantity_type",
"type": "quantity_type",
"unit": "quantity_type",
"packed as": "quantity_type",
"bag size": "bag_size",
"bag_size": "bag_size",
"kg per bag": "bag_size",
"kg/bag": "bag_size",
"bagsize": "bag_size",
"staff": "staff_name",
"staff name": "staff_name",
"packed by": "staff_name",
"operator": "staff_name",
"for order": "for_order",
"order": "for_order",
"for stock": "for_stock",
"stock": "for_stock",
"job number": "job_number",
"job": "job_number",
"job no": "job_number",
"order number": "job_number",
"stock quantity": "stock_quantity",
"stock qty": "stock_quantity",
"sample box no": "sample_box_no",
"sample box": "sample_box_no",
"scales checked": "scales_checked",
"scales": "scales_checked",
"label correct": "label_correct",
"label": "label_correct",
"bag sealed": "bag_sealed",
"sealed": "bag_sealed",
"pallet good condition": "pallet_good_condition",
"pallet": "pallet_good_condition",
"notes": "notes",
"note": "notes",
"comment": "notes",
"comments": "notes",
}
# How many row-level errors we collect before truncating, to keep the response
# (and the toast) sane on a badly-formed file.
_MAX_REPORTED_ERRORS = 50
def _normalise_header(raw: object) -> str | None:
if raw is None:
return None
key = " ".join(str(raw).strip().lower().replace("-", " ").replace("_", " ").split())
if not key:
return None
if key in _HEADER_ALIASES:
return _HEADER_ALIASES[key]
# Test weights: "test weight 1".."test weight 5" (and "tw1" style).
for n in range(1, 6):
if key in {f"test weight {n}", f"tw{n}", f"test {n}"}:
return f"test_weight_{n}"
return None
def _coerce_quantity_type(value: object) -> str | None:
if value is None:
return None
text = str(value).strip().lower()
if not text:
return None
if text in {"bag", "bags", "b"}:
return "bags"
if text in {"kg", "kgs", "kilogram", "kilograms", "bulka", "bulk"}:
return "kg"
return None
def _read_tabular_file(filename: str, content: bytes) -> tuple[list[str | None], list[tuple]]:
"""Return (headers, data_rows). Detects CSV vs .xlsx by extension/content."""
lowered = (filename or "").lower()
is_excel = lowered.endswith((".xlsx", ".xlsm", ".xls"))
if is_excel:
workbook = load_workbook(io.BytesIO(content), data_only=True, read_only=True)
ws = workbook.active
rows = [tuple(r) for r in ws.iter_rows(values_only=True)]
workbook.close()
else:
text = None
for encoding in ("utf-8-sig", "utf-8", "latin-1"):
try:
text = content.decode(encoding)
break
except UnicodeDecodeError:
continue
if text is None:
raise ValueError("Could not decode the file as text. Save it as UTF-8 CSV or .xlsx.")
# Sniff the delimiter (comma/semicolon/tab) but fall back to comma.
sample = text[:4096]
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except csv.Error:
dialect = csv.excel
rows = [tuple(r) for r in csv.reader(io.StringIO(text), dialect)]
# Find the first row that has at least one recognised header; treat it as
# the header row and everything after as data.
for index, row in enumerate(rows):
if any(_normalise_header(cell) is not None for cell in row):
return list(row), rows[index + 1 :]
return [], []
def import_entries_from_file(
db: Session,
*,
filename: str,
content: bytes,
tenant_id: str,
created_by: str | None,
) -> dict:
"""Parse an uploaded CSV/spreadsheet and persist each row as a throughput
entry. Products are matched by item_id then name, and auto-created when not
found so every entry stays linked. Returns a summary with row-level errors.
"""
headers, data_rows = _read_tabular_file(filename, content)
if not headers:
raise ValueError(
"No recognised columns found. The file needs a header row with at "
"least Date, Product and Quantity columns."
)
# Map canonical field name → column index. First occurrence wins.
field_index: dict[str, int] = {}
for col, raw in enumerate(headers):
field = _normalise_header(raw)
if field and field not in field_index:
field_index[field] = col
for required in ("date", "product", "quantity"):
if required not in field_index:
raise ValueError(
f"Missing required '{required}' column. Required columns are "
"Date, Product and Quantity."
)
def cell(row: tuple, field: str) -> object:
idx = field_index.get(field)
if idx is None or idx >= len(row):
return None
return row[idx]
# Index existing products for matching (by item_id and by lower-cased name).
by_item: dict[str, ThroughputProduct] = {}
by_name: dict[str, ThroughputProduct] = {}
for product in db.scalars(
select(ThroughputProduct).where(ThroughputProduct.tenant_id == tenant_id)
).all():
if product.item_id:
by_item[str(product.item_id)] = product
by_name[product.name.lower()] = product
imported = 0
skipped = 0
products_created = 0
errors: list[str] = []
def note_error(message: str) -> None:
if len(errors) < _MAX_REPORTED_ERRORS:
errors.append(message)
for offset, row in enumerate(data_rows):
# Sheet/file row number for human-friendly error messages (header = 1).
line_no = offset + 2
if not row or all(value is None or str(value).strip() == "" for value in row):
continue
production_date = _coerce_date(cell(row, "date"))
product_name = _coerce_text(cell(row, "product"))
quantity = _coerce_float(cell(row, "quantity"))
if production_date is None:
skipped += 1
note_error(f"Row {line_no}: missing or invalid date.")
continue
if not product_name:
skipped += 1
note_error(f"Row {line_no}: missing product name.")
continue
if quantity is None or quantity < 0:
skipped += 1
note_error(f"Row {line_no}: missing or invalid quantity.")
continue
bag_size = _coerce_float(cell(row, "bag_size"))
quantity_type = _coerce_quantity_type(cell(row, "quantity_type"))
if quantity_type is None:
# Infer: bulka-style rows have a blank or very large bag size.
if bag_size is None or bag_size >= _BULKA_BAG_SIZE_THRESHOLD or "bulka" in product_name.lower():
quantity_type = "kg"
else:
quantity_type = "bags"
if quantity_type == "bags" and (bag_size is None or bag_size <= 0):
skipped += 1
note_error(f"Row {line_no}: bag size is required when packed as bags.")
continue
item_id_raw = cell(row, "item_id")
item_id = None
if item_id_raw is not None:
if isinstance(item_id_raw, float) and item_id_raw.is_integer():
item_id = str(int(item_id_raw))
else:
item_id = _coerce_text(item_id_raw)
product = (by_item.get(item_id) if item_id else None) or by_name.get(product_name.lower())
if product is None:
product = ThroughputProduct(
tenant_id=tenant_id,
item_id=item_id,
name=product_name,
default_bag_size=bag_size,
is_bulka_default=_infer_bulka_default(product_name, bag_size),
active=True,
notes="Auto-created during throughput import",
)
db.add(product)
db.flush()
products_created += 1
if item_id:
by_item[item_id] = product
by_name[product_name.lower()] = product
for_order = _coerce_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,
}
+80 -11
View File
@@ -23,7 +23,7 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from app.models.client_access import ClientAccount from app.models.client_access import ClientAccount
from app.models.ordering import Order from app.models.ordering import Order, XeroContactLink
@dataclass @dataclass
@@ -57,11 +57,75 @@ class XeroSubmissionResult:
line_items: list[dict] = field(default_factory=list) line_items: list[dict] = field(default_factory=list)
def map_customer_to_contact(customer: ClientAccount) -> dict: @dataclass
"""Map a customer account onto a Xero contact payload.""" class XeroContact:
"""A Xero contact available to link a customer against."""
contact_id: str
name: str
email: str | None = None
status: str = "ACTIVE"
def as_dict(self) -> dict:
return {
"contact_id": self.contact_id,
"name": self.name,
"email": self.email,
"status": self.status,
}
# Deterministic sample contacts used while running in stub mode (no Xero
# credentials). They stand in for "what's in Xero" so the customer→contact
# mapping UI is usable before the live API is wired. Ids mimic Xero GUIDs.
_STUB_CONTACTS: tuple[XeroContact, ...] = (
XeroContact("STUB-CON-0001", "Hunter Premium Produce", "accounts@hunterpremium.example", "ACTIVE"),
XeroContact("STUB-CON-0002", "Mayreef Pty Ltd", "ap@mayreef.example", "ACTIVE"),
XeroContact("STUB-CON-0003", "Ian McKay Stock Feeds", "ian@mckayfeeds.example", "ACTIVE"),
XeroContact("STUB-CON-0004", "Peckish Bird Foods", "orders@peckish.example", "ACTIVE"),
XeroContact("STUB-CON-0005", "Hay & Straw Co", "info@hayandstraw.example", "ACTIVE"),
XeroContact("STUB-CON-0006", "PHF Horse Mixes", "accounts@phfhorse.example", "ACTIVE"),
)
def _fetch_contacts_from_api(config: XeroConfig) -> list[XeroContact]:
"""Live contact fetch. Stubbed until credentials/endpoints are wired.
TODO (go-live): GET ``{config.base_url}/Contacts`` with the
``Xero-tenant-id`` header, page through ``Contacts[]`` and map each onto a
:class:`XeroContact` (``ContactID``/``Name``/``EmailAddress``/``ContactStatus``).
"""
raise NotImplementedError("Live Xero contact fetch is not implemented yet.")
def list_xero_contacts(config: XeroConfig | None = None) -> tuple[list[XeroContact], bool]:
"""Return the Xero contacts available for linking and whether they're stubbed.
Never raises on a live-mode error it returns an empty list so the mapping
console still renders.
"""
config = config or XeroConfig.from_env()
if not config.configured:
return list(_STUB_CONTACTS), True
try:
return _fetch_contacts_from_api(config), False
except Exception: # pragma: no cover - defensive: never break the request path
return [], False
def map_customer_to_contact(customer: ClientAccount, link: XeroContactLink | None = None) -> dict:
"""Map a customer account onto a Xero contact payload.
When the customer has been linked to a Xero contact we send the real
``ContactID`` so Xero attaches the invoice to the existing contact. Without a
link we fall back to keying on the client code (Xero will match-or-create).
"""
if link is not None and link.xero_contact_id:
return {
"ContactID": link.xero_contact_id,
"Name": link.xero_contact_name or customer.name,
}
return { return {
# TODO: persist and reuse a real Xero ContactID once the contact has
# been created/matched in Xero. For now we key on the client code.
"ContactNumber": customer.client_code, "ContactNumber": customer.client_code,
"Name": customer.name, "Name": customer.name,
} }
@@ -76,7 +140,9 @@ def map_product_to_item_code(product_sku: str) -> str:
return product_sku return product_sku
def build_invoice_payload(order: Order, customer: ClientAccount) -> dict: def build_invoice_payload(
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
) -> dict:
"""Build the Xero draft-invoice payload for a confirmed order.""" """Build the Xero draft-invoice payload for a confirmed order."""
line_items = [] line_items = []
for line in order.lines: for line in order.lines:
@@ -98,7 +164,7 @@ def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
return { return {
"Type": "ACCREC", "Type": "ACCREC",
"Status": "DRAFT", "Status": "DRAFT",
"Contact": map_customer_to_contact(customer), "Contact": map_customer_to_contact(customer, link),
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}", "Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
"LineAmountTypes": "Exclusive", "LineAmountTypes": "Exclusive",
"LineItems": line_items, "LineItems": line_items,
@@ -127,14 +193,17 @@ def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResu
) )
def submit_order_to_xero(order: Order, customer: ClientAccount) -> XeroSubmissionResult: def submit_order_to_xero(
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
) -> XeroSubmissionResult:
"""Submit a confirmed order to Xero, or stub it when unconfigured. """Submit a confirmed order to Xero, or stub it when unconfigured.
Never raises failures are returned as ``status="failed"`` results so the Pass ``link`` to invoice against the customer's mapped Xero contact. Never
order lifecycle can record the attempt and continue. raises failures are returned as ``status="failed"`` results so the order
lifecycle can record the attempt and continue.
""" """
config = XeroConfig.from_env() config = XeroConfig.from_env()
payload = build_invoice_payload(order, customer) payload = build_invoice_payload(order, customer, link)
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}" summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
if not config.configured: if not config.configured:
+2 -1
View File
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "data-entry-app-backend" name = "data-entry-app-backend"
version = "0.1.14" version = "0.1.19"
description = "Costing platform MVP backend" description = "Costing platform MVP backend"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"fastapi>=0.115,<1.0", "fastapi>=0.115,<1.0",
"python-multipart>=0.0.9,<1.0",
"openpyxl>=3.1,<4.0", "openpyxl>=3.1,<4.0",
"rich>=13.9,<15.0", "rich>=13.9,<15.0",
"uvicorn[standard]>=0.30,<1.0", "uvicorn[standard]>=0.30,<1.0",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.12", "version": "0.1.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.12", "version": "0.1.18",
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1" "lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.14", "version": "0.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+100
View File
@@ -0,0 +1,100 @@
/**
* Modern hover/focus tooltip action.
*
* Renders a styled bubble appended to <body> with fixed positioning, so it is
* never clipped by an overflow:hidden ancestor (e.g. the topbar). Themes via the
* shared design tokens see `.app-tooltip` in styles/theme.css.
*
* Usage: <button use:tooltip={'Switch to dark mode'}></button>
* <button use:tooltip={{ label: 'Whats new', placement: 'bottom' }}></button>
*/
type Placement = 'top' | 'bottom';
type TooltipOptions = string | { label: string; placement?: Placement; delay?: number };
const GAP = 8;
const DEFAULT_DELAY = 300;
function normalize(options: TooltipOptions): { label: string; placement: Placement; delay: number } {
if (typeof options === 'string') {
return { label: options, placement: 'bottom', delay: DEFAULT_DELAY };
}
return {
label: options.label,
placement: options.placement ?? 'bottom',
delay: options.delay ?? DEFAULT_DELAY
};
}
export function tooltip(node: HTMLElement, options: TooltipOptions) {
let current = normalize(options);
let bubble: HTMLDivElement | null = null;
let showTimer: ReturnType<typeof setTimeout> | null = null;
function position() {
if (!bubble) return;
const anchor = node.getBoundingClientRect();
const tip = bubble.getBoundingClientRect();
let left = anchor.left + anchor.width / 2 - tip.width / 2;
left = Math.max(GAP, Math.min(left, window.innerWidth - tip.width - GAP));
const top =
current.placement === 'top'
? anchor.top - tip.height - GAP
: anchor.bottom + GAP;
bubble.style.left = `${Math.round(left)}px`;
bubble.style.top = `${Math.round(top)}px`;
}
function show() {
if (bubble || !current.label) return;
bubble = document.createElement('div');
bubble.className = 'app-tooltip';
bubble.setAttribute('role', 'tooltip');
bubble.dataset.placement = current.placement;
bubble.textContent = current.label;
document.body.appendChild(bubble);
position();
// Trigger the fade/translate transition on the next frame.
requestAnimationFrame(() => bubble?.classList.add('is-visible'));
}
function scheduleShow() {
clearTimeout(showTimer ?? undefined);
showTimer = setTimeout(show, current.delay);
}
function hide() {
clearTimeout(showTimer ?? undefined);
showTimer = null;
bubble?.remove();
bubble = null;
}
node.addEventListener('mouseenter', scheduleShow);
node.addEventListener('mouseleave', hide);
node.addEventListener('focus', show);
node.addEventListener('blur', hide);
node.addEventListener('click', hide);
return {
update(next: TooltipOptions) {
current = normalize(next);
if (bubble) {
bubble.textContent = current.label;
bubble.dataset.placement = current.placement;
position();
}
},
destroy() {
hide();
node.removeEventListener('mouseenter', scheduleShow);
node.removeEventListener('mouseleave', hide);
node.removeEventListener('focus', show);
node.removeEventListener('blur', hide);
node.removeEventListener('click', hide);
}
};
}
+109 -2
View File
@@ -9,6 +9,11 @@ import type {
ClientUserUpdateInput, ClientUserUpdateInput,
LoginResponse, LoginResponse,
EditorMixUpdateInput, EditorMixUpdateInput,
EditorMixRow,
EditorMixFormula,
EditorIngredientRow,
EditorIngredientCreateInput,
EditorIngredientUpdateInput,
EditorProductFormula, EditorProductFormula,
EditorProductRow, EditorProductRow,
EditorProductUpdateInput, EditorProductUpdateInput,
@@ -38,10 +43,14 @@ import type {
OrderingCustomerUser, OrderingCustomerUser,
OrderingNotificationSettings, OrderingNotificationSettings,
XeroStatus, XeroStatus,
XeroContactList,
XeroContactLinkRow,
Scenario, Scenario,
ThroughputEntry, ThroughputEntry,
ThroughputEntryCreateInput, ThroughputEntryCreateInput,
ThroughputEntryUpdateInput,
ThroughputEntryListParams, ThroughputEntryListParams,
ThroughputImportResult,
ThroughputProduct, ThroughputProduct,
ThroughputProductCreateInput, ThroughputProductCreateInput,
ThroughputProductUpdateInput ThroughputProductUpdateInput
@@ -250,6 +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( async function requestBlob(
path: string, path: string,
options: RequestInit = {}, options: RequestInit = {},
@@ -330,11 +378,36 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
editorMixes: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.q) search.set('q', params.q);
if (params?.client_name) search.set('client_name', params.client_name);
if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString();
const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes';
return cachedFetchJson<EditorMixRow[]>(path, 'client', fetcher);
},
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) => updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
request<EditorProductRow[]>(`/api/editor/mixes/${mixId}`, { request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
editorMixFormula: (mixId: number) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteEditorMixIngredient: (mixId: number, ingredientId: number) =>
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
method: 'DELETE'
}, 'client'),
editorProductFormula: (productId: number) => editorProductFormula: (productId: number) =>
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {}, 'client'), request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {}, 'client'),
addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) => addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
@@ -351,6 +424,18 @@ export const api = {
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, { request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
method: 'DELETE' method: 'DELETE'
}, 'client'), }, 'client'),
editorIngredients: (fetcher?: ApiFetch) =>
cachedFetchJson<EditorIngredientRow[]>('/api/editor/ingredients', 'client', fetcher),
createEditorIngredient: (payload: EditorIngredientCreateInput) =>
request<EditorIngredientRow>('/api/editor/ingredients', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateEditorIngredient: (ingredientId: number, payload: EditorIngredientUpdateInput) =>
request<EditorIngredientRow>(`/api/editor/ingredients/${ingredientId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
productCosts: (fetcher?: ApiFetch) => productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher), cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
productCostingItems: (fetcher?: ApiFetch) => productCostingItems: (fetcher?: ApiFetch) =>
@@ -391,6 +476,18 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
updateThroughputEntry: (entryId: number, payload: ThroughputEntryUpdateInput) =>
request<ThroughputEntry>(`/api/throughput/entries/${entryId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
deleteThroughputEntry: (entryId: number) =>
request<void>(`/api/throughput/entries/${entryId}`, { method: 'DELETE' }, 'client'),
importThroughputEntries: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return uploadFile<ThroughputImportResult>('/api/throughput/import', formData, 'client');
},
createThroughputProduct: (payload: ThroughputProductCreateInput) => createThroughputProduct: (payload: ThroughputProductCreateInput) =>
request<ThroughputProduct>('/api/throughput/products', { request<ThroughputProduct>('/api/throughput/products', {
method: 'POST', method: 'POST',
@@ -579,6 +676,16 @@ export const api = {
cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher), cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher),
updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) => updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) =>
request<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), request<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher) xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher),
xeroContacts: (fetcher?: ApiFetch) =>
cachedFetchJson<XeroContactList>('/api/ordering-admin/xero/contacts', 'client', fetcher),
xeroContactLinks: (fetcher?: ApiFetch) =>
cachedFetchJson<XeroContactLinkRow[]>('/api/ordering-admin/xero/contact-links', 'client', fetcher),
linkCustomerToXero: (
customerId: number,
payload: { xero_contact_id: string; xero_contact_name?: string | null; xero_contact_email?: string | null }
) => request(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
unlinkCustomerFromXero: (customerId: number) =>
request<void>(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'DELETE' }, 'client')
} }
}; };
+19 -5
View File
@@ -8,7 +8,7 @@ import packageInfo from '../../package.json';
*/ */
export type ChangelogEntry = { export type ChangelogEntry = {
version: string; version: string;
/** ISO date (YYYY-MM-DD) the version shipped. */ /** ISO date (YYYY-MM-DD) the version shipped. */a
date: string; date: string;
highlights: string[]; highlights: string[];
}; };
@@ -17,14 +17,28 @@ export type ChangelogEntry = {
export const APP_VERSION: string = packageInfo.version; export const APP_VERSION: string = packageInfo.version;
export const changelog: ChangelogEntry[] = [ export const changelog: ChangelogEntry[] = [
{
version: '0.1.18',
date: '2026-06-12',
highlights: [
'App - Improvements',
'App - Bug fixes'
]
},
{
version: '0.1.17',
date: '2026-06-12',
highlights: [
'App - Improvements',
'App - Mix Calculator bug fixes'
]
},
{ {
version: '0.1.14', version: '0.1.14',
date: '2026-06-11', date: '2026-06-11',
highlights: [ highlights: [
'New: private B2B customer ordering portal — customers browse their catalogue, see account-specific pricing, and submit orders.', 'Web App: Improved mix calculator',
'Order management console for internal staff: review orders, manage products, pricing, and the full order lifecycle.', 'Web App: Improved design'
'Customer-specific pricing engine (fixed, contract, price lists, tiered, and quote-only) calculated on the backend.',
'Order confirmations (PDF) and Xero submission, behind a clean integration layer.'
] ]
}, },
{ {
+111 -8
View File
@@ -9,43 +9,146 @@
</script> </script>
{#if blocked} {#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> <p class="auth-gate-label">{label}</p>
<h2>{title}</h2> <h2>{title}</h2>
<p>{detail}</p> <p class="auth-gate-detail">{detail}</p>
</section> </section>
</div>
{:else} {:else}
{@render children()} {@render children()}
{/if} {/if}
<style> <style>
.auth-gate-screen {
display: grid;
place-items: center;
width: 100%;
min-height: 100%;
height: 100%;
padding: 2rem;
}
.auth-gate-card { .auth-gate-card {
display: grid; display: grid;
justify-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 1.35rem 1.4rem; width: min(26rem, 100%);
padding: 2.1rem 2rem 2.25rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 1rem; border-radius: 1.25rem;
background: var(--panel); background: var(--panel);
text-align: center;
box-shadow: 0 18px 50px -24px rgba(0, 0, 0, 0.4);
animation: auth-gate-in 380ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* ── Animated loader ───────────────────────────────────────── */
.auth-gate-loader {
position: relative;
width: 3.1rem;
height: 3.1rem;
margin-bottom: 0.65rem;
}
.auth-gate-loader .ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2.5px solid transparent;
border-top-color: var(--color-brand, #2f9e6f);
border-right-color: color-mix(in srgb, var(--color-brand, #2f9e6f) 45%, transparent);
animation: auth-gate-spin 0.85s linear infinite;
}
.auth-gate-loader .ring-2 {
inset: 0.42rem;
border-top-color: color-mix(in srgb, var(--color-brand, #2f9e6f) 60%, transparent);
border-right-color: transparent;
animation-duration: 1.25s;
animation-direction: reverse;
}
.auth-gate-loader .dot {
position: absolute;
inset: 0;
margin: auto;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--color-brand, #2f9e6f);
animation: auth-gate-pulse 1.1s ease-in-out infinite;
} }
.auth-gate-label { .auth-gate-label {
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
font-size: 0.76rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
} }
.auth-gate-card h2 { .auth-gate-card h2 {
margin: 0; margin: 0;
font-size: 1.18rem; font-size: 1.2rem;
} }
.auth-gate-card p:last-child { .auth-gate-detail {
margin: 0; margin: 0;
max-width: 22rem;
color: var(--muted); color: var(--muted);
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.55; line-height: 1.55;
} }
@keyframes auth-gate-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes auth-gate-spin {
to {
transform: rotate(360deg);
}
}
@keyframes auth-gate-pulse {
0%,
100% {
transform: scale(0.7);
opacity: 0.55;
}
50% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.auth-gate-card {
animation: none;
}
.auth-gate-loader .ring,
.auth-gate-loader .ring-2 {
animation-duration: 1.6s;
}
.auth-gate-loader .dot {
animation: none;
}
}
</style> </style>
+57 -12
View File
@@ -15,7 +15,6 @@
import { import {
canCreateMixSession as sessionCanCreateMixSession, canCreateMixSession as sessionCanCreateMixSession,
canCreateMixWorksheet as sessionCanCreateMixWorksheet, canCreateMixWorksheet as sessionCanCreateMixWorksheet,
canOpenClientAccess as sessionCanOpenClientAccess,
canOpenDashboard as sessionCanOpenDashboard, canOpenDashboard as sessionCanOpenDashboard,
canOpenEditor as sessionCanOpenEditor, canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator, canOpenMixCalculator as sessionCanOpenMixCalculator,
@@ -32,21 +31,24 @@
isWorkspaceRouteAllowed isWorkspaceRouteAllowed
} from '$lib/workspace-access'; } from '$lib/workspace-access';
import { import {
accessControlItem,
baseSearchItems, baseSearchItems,
buildClientNavEntries, buildClientNavEntries,
clientBreadcrumbs, clientBreadcrumbs,
dashboardItem, dashboardItem,
editorItem, editorItem,
ingredientsEditorItem,
footerLinks, footerLinks,
matchesRoute, matchesRoute,
mixCalculatorItem, mixCalculatorItem,
orderingItem, orderingItem,
orderingManageChildren,
orderingManageGroup,
pageTitle, pageTitle,
productCostingItem, productCostingItem,
reportingItem, reportingItem,
throughputItem, throughputItem,
type FooterLink, type FooterLink,
type NavEntry,
type SearchItem, type SearchItem,
type NavItem, type NavItem,
workingDocumentItems workingDocumentItems
@@ -89,7 +91,6 @@
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession)); const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession)); const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession)); const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
const canOpenClientAccess = $derived(sessionCanOpenClientAccess($clientSession));
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession)); const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession)); const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname)); const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
@@ -116,15 +117,19 @@
// (/ordering/manage), customers get the catalogue (/ordering). // (/ordering/manage), customers get the catalogue (/ordering).
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession)); const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($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 canManageOrdering
? { ...orderingItem, href: '/ordering/manage', label: 'Order Management', shortLabel: 'OM' } ? { kind: 'group', group: orderingManageGroup }
: canOpenCustomerOrdering : canOpenCustomerOrdering
? orderingItem ? { kind: 'item', item: orderingItem }
: null : null
); );
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null); const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null); const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the // Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the
// standalone operations/insights modules. Built from the same access-filtered // standalone operations/insights modules. Built from the same access-filtered
// items, so a role only ever sees the families it may open. // items, so a role only ever sees the families it may open.
@@ -135,20 +140,18 @@
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []), ...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []), ...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []), ...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
...visibleWorkingDocumentItems ...visibleWorkingDocumentItems
], ],
throughput: visibleThroughputItem, throughput: visibleThroughputItem,
ordering: visibleOrderingItem, ordering: visibleOrderingEntry,
reporting: visibleReportingItem reporting: visibleReportingItem
}) })
); );
const isOperationsUser = $derived($clientSession?.role_name === 'Operations'); const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession)); const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([ const visibleFooterLinks = $derived([
...(!isOperationsUser ? footerLinks : []), ...(!isOperationsUser ? footerLinks : [])
...(!canOpenClientAccess
? []
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel, icon: accessControlItem.icon }])
] as FooterLink[]); ] as FooterLink[]);
const primaryBottomNavigation = $derived( const primaryBottomNavigation = $derived(
[ [
@@ -169,6 +172,7 @@
if (item.href === '/mix-calculator') return canOpenMixCalculator; if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession); if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession);
if (item.href === '/editor') return canOpenEditor; if (item.href === '/editor') return canOpenEditor;
if (item.href === '/ingredients') return canOpenEditor;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession); if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings; if (item.href === '/settings') return canOpenSettings;
return true; 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) => { searchItems.filter((item) => {
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase(); const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
return haystack.includes(paletteQuery.trim().toLowerCase()); return haystack.includes(paletteQuery.trim().toLowerCase());
}) })
); );
const filteredSearchItems = $derived(matchingSearchItems.slice(0, PALETTE_RESULT_LIMIT));
const hiddenResultCount = $derived(matchingSearchItems.length - filteredSearchItems.length);
$effect(() => { $effect(() => {
page.url.pathname; page.url.pathname;
@@ -476,6 +486,7 @@
}} }}
onOpenSettings={openSettings} onOpenSettings={openSettings}
onSignOut={signOut} onSignOut={signOut}
onShowWhatsNew={() => (whatsNewOpen = true)}
/> />
<main class="content"> <main class="content">
@@ -627,6 +638,29 @@
</a> </a>
{/if} {/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} {#if visibleWorkingDocumentItems.length}
<div class="drawer-sublist" id="drawer-working-documents-nav"> <div class="drawer-sublist" id="drawer-working-documents-nav">
{#each visibleWorkingDocumentItems as item} {#each visibleWorkingDocumentItems as item}
@@ -724,6 +758,9 @@
<small>{item.href}</small> <small>{item.href}</small>
</button> </button>
{/each} {/each}
{#if hiddenResultCount > 0}
<p class="palette-more">{hiddenResultCount} more {hiddenResultCount === 1 ? 'match' : 'matches'} — keep typing to narrow.</p>
{/if}
{:else} {:else}
<div class="palette-empty"> <div class="palette-empty">
<strong>No results</strong> <strong>No results</strong>
@@ -1203,6 +1240,14 @@
justify-content: flex-start; 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-nav,
.bottom-drawer { .bottom-drawer {
display: none; display: none;
@@ -103,7 +103,7 @@
<td> <td>
<strong>{line.raw_material_name}</strong> <strong>{line.raw_material_name}</strong>
</td> </td>
<td>{formatNumber(line.required_kg, 2)}kg</td> <td>{formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -1,16 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Moon, Sun } from 'lucide-svelte'; import { Moon, Sun } from 'lucide-svelte';
import { resolvedTheme, toggleTheme } from '$lib/theme'; import { resolvedTheme, toggleTheme } from '$lib/theme';
import { tooltip } from '$lib/actions/tooltip';
const isDark = $derived($resolvedTheme === 'dark'); const isDark = $derived($resolvedTheme === 'dark');
const label = $derived(
isDark ? 'Switch to light mode' : 'Switch to dark mode'
);
</script> </script>
<button <button
class="theme-toggle" class="theme-toggle"
type="button" type="button"
onclick={toggleTheme} onclick={toggleTheme}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'} aria-label={label}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'} use:tooltip={label}
> >
{#if isDark} {#if isDark}
<Sun size={18} strokeWidth={1.75} /> <Sun size={18} strokeWidth={1.75} />
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Download, Printer } from 'lucide-svelte'; import { ArrowUpDown, Download, Printer } from 'lucide-svelte';
import { formatDate, formatNumber } from '$lib/format'; import { formatDate, formatNumber } from '$lib/format';
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types'; import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
@@ -15,6 +15,37 @@
onDownloadPdf?: (() => void) | null; onDownloadPdf?: (() => void) | null;
} = $props(); } = $props();
// ── Ingredient sorting ──────────────────────────────────────────
// Default to heaviest ingredient first; clicking a header toggles direction
// (or switches column). Required kg starts descending, the name ascending.
type LineSortKey = 'raw_material_name' | 'required_kg';
let sortKey = $state<LineSortKey>('required_kg');
let sortDir = $state<'asc' | 'desc'>('desc');
function toggleSort(key: LineSortKey) {
if (sortKey === key) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
return;
}
sortKey = key;
sortDir = key === 'required_kg' ? 'desc' : 'asc';
}
function ariaSort(key: LineSortKey): 'ascending' | 'descending' | 'none' {
if (sortKey !== key) return 'none';
return sortDir === 'asc' ? 'ascending' : 'descending';
}
const sortedLines = $derived.by(() => {
const dir = sortDir === 'asc' ? 1 : -1;
return [...(preview?.lines ?? [])].sort((a, b) => {
const result =
sortKey === 'required_kg'
? (a.required_kg ?? 0) - (b.required_kg ?? 0)
: a.raw_material_name.localeCompare(b.raw_material_name, undefined, { sensitivity: 'base' });
return result * dir;
});
});
</script> </script>
<article class="result-card"> <article class="result-card">
@@ -81,17 +112,37 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Raw material</th> <th aria-sort={ariaSort('raw_material_name')}>
<th>Required kg</th> <button
type="button"
class="sort-head"
class:active={sortKey === 'raw_material_name'}
onclick={() => toggleSort('raw_material_name')}
>
<span>Raw material</span>
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
</button>
</th>
<th aria-sort={ariaSort('required_kg')}>
<button
type="button"
class="sort-head"
class:active={sortKey === 'required_kg'}
onclick={() => toggleSort('required_kg')}
>
<span>Required kg</span>
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
</button>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each preview.lines as line} {#each sortedLines as line}
<tr> <tr>
<td data-label="Raw material"> <td data-label="Raw material">
<strong>{line.raw_material_name}</strong> <strong>{line.raw_material_name}</strong>
</td> </td>
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td> <td data-label="Required kg">{formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -278,6 +329,42 @@
text-transform: uppercase; text-transform: uppercase;
} }
/* Clickable header: inherits the th look, adds a sort affordance. */
.sort-head {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
transition: color 140ms ease;
}
.sort-head :global(svg) {
opacity: 0.45;
transition: opacity 140ms ease;
}
.sort-head:hover,
.sort-head.active {
color: var(--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, .primary-button,
.secondary-button { .secondary-button {
display: inline-flex; display: inline-flex;
@@ -57,7 +57,6 @@
.secondary-rail-layout-content > :global(*) { .secondary-rail-layout-content > :global(*) {
flex: 1 0 auto; flex: 1 0 auto;
height: 100%;
min-height: 100%; min-height: 100%;
} }
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import { ChevronDown, LogOut, Settings } from 'lucide-svelte'; import { ChevronDown, LogOut, Settings } from 'lucide-svelte';
import type { ComponentType } from 'svelte'; import type { ComponentType } from 'svelte';
@@ -68,35 +69,133 @@
return null; return null;
}); });
// Open the active group once each time it changes. Because this only fires on // Open the active group once each time it changes, collapsing any other open
// a *change* of activeGroupId, a user who manually closes the group they're // group so only one family is ever expanded (accordion). Because this only
// standing in won't have it reopened under them. // fires on a *change* of activeGroupId, a user who manually closes the group
// they're standing in won't have it reopened under them.
$effect(() => { $effect(() => {
const id = activeGroupId; const id = activeGroupId;
if (id && lastAutoExpanded !== id) { if (id) {
if (!openGroups[id]) { if (lastAutoExpanded !== id) {
openGroups[id] = true; openGroups = { [id]: true };
persistOpenState(); persistOpenState();
}
lastAutoExpanded = id; lastAutoExpanded = id;
} }
} else if (lastAutoExpanded !== null) {
// Just landed on a standalone module (Dashboard, Throughput, Reporting)
// from inside a family. Collapse the open family so the rail tidies itself,
// and forget the last auto-expanded group so returning to it — say
// Throughput → Order Management — fires the expand again. The
// `lastAutoExpanded !== null` guard makes this a one-shot per transition, so
// a group the user manually opens while on a standalone page stays open.
openGroups = {};
persistOpenState();
lastAutoExpanded = null;
}
}); });
const isOpen = (id: string) => openGroups[id] ?? false; const isOpen = (id: string) => openGroups[id] ?? false;
// Accordion: opening a group collapses every other group; closing just shuts
// the one. So expanding Order Management folds an already-open Costing away.
function toggleGroup(id: string) { function toggleGroup(id: string) {
openGroups[id] = !isOpen(id); openGroups = isOpen(id) ? {} : { [id]: true };
persistOpenState(); persistOpenState();
} }
const moduleCount = $derived.by(() => // Expand a group without ever collapsing it. Used by a linkable group header
entries.reduce((count, entry) => count + (entry.kind === 'item' ? 1 : entry.group.children.length), 0) // (Order Management) so clicking the label reveals its submenu the same way a
); // toggle group does — the route may not change (you're already on its page),
// so the auto-expand effect can't be relied on to open it. The chevron remains
// the way to collapse the family.
function openGroup(id: string) {
if (isOpen(id)) return;
openGroups = { [id]: true };
persistOpenState();
}
// ── Third-level submenus (e.g. Integrations → Xero) ─────────────
// Tracked independently of the top-level accordion: a nested submenu can be
// open at the same time as its parent group, and toggling a top-level family
// must not wipe a sibling's nested state. Keyed by "<groupId>:<childHref>".
const SUB_STORAGE_KEY = 'hsf:nav:open-subgroups';
function restoreSubState(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
try {
return JSON.parse(window.sessionStorage.getItem(SUB_STORAGE_KEY) ?? '{}');
} catch {
return {};
}
}
let openSubGroups = $state<Record<string, boolean>>(restoreSubState());
let lastAutoExpandedSub = $state<string | null>(null);
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> </script>
{#snippet leafLink(item: NavItem, showIcon: boolean)} {#snippet leafLink(item: NavItem, showIcon: boolean)}
{@const Icon = item.icon} {@const Icon = item.icon}
<a class="rail-row" class:active={matchesRoute(item.href, currentPath)} href={item.href}> <a class="rail-row" class:active={matchesRoute(item.href, currentPath, item.exact)} href={item.href}>
{#if showIcon && Icon} {#if showIcon && Icon}
<span class="rail-icon"><Icon size={18} strokeWidth={1.75} /></span> <span class="rail-icon"><Icon size={18} strokeWidth={1.75} /></span>
{/if} {/if}
@@ -120,14 +219,12 @@
<span class="brand-wordmark">Hunter Premium Produce</span> <span class="brand-wordmark">Hunter Premium Produce</span>
<span class="brand-subtitle">Operations workspace</span> <span class="brand-subtitle">Operations workspace</span>
</a> </a>
<span class="module-pill">{moduleCount} modules</span>
</div> </div>
<div class="sidebar-body"> <div class="sidebar-body">
<div class="rail-scroll"> <div class="rail-scroll">
<div class="rail-section-head"> <div class="rail-section-head">
<p class="rail-section-label">Modules</p> <p class="rail-section-label">Modules</p>
<span class="rail-section-count">{moduleCount}</span>
</div> </div>
<nav class="rail-nav" aria-label="Workspace navigation"> <nav class="rail-nav" aria-label="Workspace navigation">
@@ -139,7 +236,38 @@
{@const GroupIcon = group.icon} {@const GroupIcon = group.icon}
{@const groupActive = groupHasActiveChild(group, currentPath)} {@const groupActive = groupHasActiveChild(group, currentPath)}
{@const open = isOpen(group.id)} {@const open = isOpen(group.id)}
{@const headerHref = group.href ?? group.children[0]?.href}
<div class="rail-group"> <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 <button
type="button" type="button"
class="rail-row rail-group-toggle" class="rail-row rail-group-toggle"
@@ -150,17 +278,54 @@
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span> <span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{group.label}</span> <span class="rail-text">{group.label}</span>
<span class="rail-group-meta"> <span class="rail-group-meta">
<span class="rail-group-count">{group.children.length}</span>
<span class="rail-chevron" class:open aria-hidden="true"> <span class="rail-chevron" class:open aria-hidden="true">
<ChevronDown size={15} strokeWidth={2} /> <ChevronDown size={15} strokeWidth={2} />
</span> </span>
</span> </span>
</button> </button>
{/if}
{#if open} {#if open}
<div class="rail-children"> <div class="rail-children">
{#each group.children as child} {#each group.children as child}
{#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)} {@render leafLink(child, false)}
{/if}
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -255,20 +420,6 @@
padding: 0 0.5rem; padding: 0 0.5rem;
} }
.rail-section-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
height: 1.35rem;
padding: 0 0.42rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
color: var(--sidebar-text-muted);
font-size: 0.7rem;
font-weight: 700;
}
.brand-row { .brand-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -309,20 +460,6 @@
line-height: 1.3; line-height: 1.3;
} }
.module-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.34rem 0.58rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-active-bg) 10%, transparent);
color: var(--sidebar-active-bg);
font-size: 0.7rem;
font-weight: 700;
white-space: nowrap;
}
/* ── Navigation rows ─────────────────────────────────────────── */ /* ── Navigation rows ─────────────────────────────────────────── */
.rail-nav { .rail-nav {
display: grid; display: grid;
@@ -425,32 +562,91 @@
gap: 0.42rem; gap: 0.42rem;
} }
.rail-group-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.2rem;
padding: 0 0.32rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
color: var(--sidebar-text-muted);
font-size: 0.66rem;
font-weight: 700;
line-height: 1;
}
.rail-group-toggle.within-active { .rail-group-toggle.within-active {
color: var(--sidebar-text-strong); color: var(--sidebar-text-strong);
font-weight: 600; font-weight: 600;
} }
.rail-group-toggle.within-active .rail-group-count { .rail-group-toggle.within-active .rail-icon,
.rail-group-toggle.within-active .rail-chevron {
color: var(--sidebar-text-strong); color: var(--sidebar-text-strong);
} }
.rail-group-toggle.within-active .rail-icon, /* ── Linkable group header (label links, chevron toggles) ────── */
.rail-group-toggle.within-active .rail-chevron { /* The label and chevron stay separate click targets (navigate vs toggle) but
share one hover pill on the wrapper, so the whole header — icon, title,
chevron — lights up as a single button instead of two halves. */
.rail-group-head {
display: flex;
align-items: center;
gap: 0;
border-radius: 0.8rem;
transition: background-color 160ms ease;
}
.rail-group-head:hover {
background: var(--sidebar-hover);
}
.rail-group-head:hover .rail-group-link,
.rail-group-head:hover .rail-icon,
.rail-group-head:hover .rail-chevron {
color: var(--sidebar-text-strong);
}
/* Let the wrapper own the hover background; the inner targets stay transparent
so they don't paint a second, mismatched pill on top. */
.rail-group-head .rail-group-link:hover,
.rail-group-head .rail-chevron-btn:hover {
background: transparent;
}
/* Category headers carry the lighter second-level row treatment (size, weight,
radius); only the leading icon and chevron mark them as parents. Standalone
top-level destinations (Dashboard, Throughput) keep the larger base row. */
.rail-group-head .rail-group-link,
.rail-group-toggle {
min-height: 2.45rem;
padding: 0.48rem 0.62rem 0.48rem 0.72rem;
font-size: 0.88rem;
border-radius: 0.8rem;
}
.rail-group-head .rail-group-link {
flex: 1;
min-width: 0;
}
.rail-group-head.within-active .rail-group-link {
color: var(--sidebar-text-strong);
font-weight: 600;
}
.rail-group-head.within-active .rail-group-link .rail-icon {
color: var(--sidebar-text-strong);
}
.rail-chevron-btn {
display: inline-flex;
align-items: center;
gap: 0.42rem;
flex-shrink: 0;
min-height: 2.45rem;
padding: 0 0.58rem;
border: none;
border-radius: 0.8rem;
background: transparent;
color: var(--sidebar-icon);
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease;
}
.rail-chevron-btn:hover {
background: var(--sidebar-hover);
color: var(--sidebar-text-strong);
}
.rail-group-head.within-active .rail-chevron-btn {
color: var(--sidebar-text-strong); color: var(--sidebar-text-strong);
} }
@@ -493,6 +689,23 @@
border-radius: 0.8rem; border-radius: 0.8rem;
} }
/* Third level: nest the submenu a little deeper than its parent row, with its
own guide line, so the hierarchy reads as Group Section Item. */
.rail-subgroup-head {
margin-left: 0.1rem;
}
.rail-subchildren {
margin-left: 0.5rem;
padding-left: 0.95rem;
animation: none;
}
.rail-subchildren .rail-row {
min-height: 2.2rem;
font-size: 0.85rem;
}
@keyframes rail-reveal { @keyframes rail-reveal {
from { from {
opacity: 0; opacity: 0;
@@ -531,14 +744,20 @@
font-size: 0.76rem; font-size: 0.76rem;
} }
.sidebar-meta-top, .sidebar-meta-top {
.sidebar-meta-bottom {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.65rem; gap: 0.65rem;
} }
.sidebar-meta-bottom {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.45rem;
}
.sidebar-meta-foot small { .sidebar-meta-foot small {
font-size: 0.72rem; font-size: 0.72rem;
line-height: 1.35; line-height: 1.35;
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { Settings } from 'lucide-svelte'; import { Settings, Sparkles } from 'lucide-svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte'; import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
import { tooltip } from '$lib/actions/tooltip';
import type { AppSession } from '$lib/session'; import type { AppSession } from '$lib/session';
import type { Crumb } from '$lib/navigation/client-navigation'; import type { Crumb } from '$lib/navigation/client-navigation';
@@ -18,7 +19,8 @@
onOpenPalette, onOpenPalette,
onToggleUserMenu, onToggleUserMenu,
onOpenSettings, onOpenSettings,
onSignOut onSignOut,
onShowWhatsNew
}: { }: {
breadcrumbs: Crumb[]; breadcrumbs: Crumb[];
title: string; title: string;
@@ -32,6 +34,7 @@
onToggleUserMenu: () => void; onToggleUserMenu: () => void;
onOpenSettings: () => void; onOpenSettings: () => void;
onSignOut: () => void; onSignOut: () => void;
onShowWhatsNew: () => void;
} = $props(); } = $props();
</script> </script>
@@ -64,6 +67,16 @@
{/if} {/if}
<div class="topbar-actions"> <div class="topbar-actions">
<button
class="whats-new-toggle"
type="button"
onclick={onShowWhatsNew}
aria-label="What's new"
use:tooltip={"What's new — latest updates and changes"}
>
<Sparkles size={18} strokeWidth={1.75} />
</button>
<ThemeToggle /> <ThemeToggle />
<div class="menu-wrap user-menu-wrap"> <div class="menu-wrap user-menu-wrap">
@@ -122,7 +135,10 @@
<style> <style>
.topbar { .topbar {
display: grid; 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; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.72rem 1.2rem; padding: 0.72rem 1.2rem;
@@ -141,7 +157,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
padding-right: 0.9rem; /* Extra right padding gives the scaled-up logo room before the divider. */
padding-right: 1.6rem;
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
} }
@@ -149,6 +166,16 @@
height: 2rem; height: 2rem;
width: auto; width: auto;
display: block; display: block;
/* Zoom the wide logo lockup in so the wordmark is legible, without growing
the header: transform scales the visual only, leaving the 2rem layout box
(and therefore the topbar height) untouched. Anchored left so it grows
rightward from the topbar's left padding edge. */
transform: scale(1.45);
transform-origin: left center;
}
.topbar-copy {
min-width: 0;
} }
.topbar-copy h1 { .topbar-copy h1 {
@@ -156,6 +183,11 @@
font-size: 1.34rem; font-size: 1.34rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; 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 { .breadcrumbs {
@@ -202,11 +234,33 @@
.topbar-actions { .topbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.68rem; gap: 0.6rem;
flex-wrap: wrap; /* Never let the What's-new / theme toggles wrap above the user button. */
flex-wrap: nowrap;
justify-content: flex-end; justify-content: flex-end;
} }
/* Matches the ThemeToggle button so the two sit as a pair. */
.whats-new-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
flex-shrink: 0;
border: 1px solid var(--color-border);
border-radius: 0.82rem;
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.whats-new-toggle:hover {
background: var(--color-surface-hover);
color: var(--color-text-primary);
}
.workspace-label { .workspace-label {
color: var(--muted); color: var(--muted);
font-size: 0.76rem; font-size: 0.76rem;
@@ -390,7 +444,10 @@
background: var(--panel-soft); 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 { .topbar {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas: grid-template-areas:
@@ -417,13 +474,19 @@
} }
.topbar-brand { .topbar-brand {
padding-right: 0.6rem; padding-right: 1.1rem;
} }
.topbar-brand img { .topbar-brand img {
height: 1.6rem; height: 1.6rem;
} }
/* On phones let the user button drop onto its own line below the icon
toggles again (the desktop nowrap rule would otherwise overflow). */
.topbar-actions {
flex-wrap: wrap;
}
.user-trigger { .user-trigger {
min-width: auto; min-width: auto;
width: 100%; width: 100%;
@@ -61,6 +61,15 @@
} }
}); });
// Reflect a selection set from outside (e.g. when an entry is loaded into the
// composer to be edited) so the search box shows the chosen product, not blank.
$effect(() => {
if (productId && !focused) {
const match = products.find((p) => String(p.id) === productId);
if (match) query = label(match);
}
});
// If the active client no longer contains the selected product, drop it. // If the active client no longer contains the selected product, drop it.
$effect(() => { $effect(() => {
if (selected && clientName && (selected.client_name ?? '') !== clientName) { if (selected && clientName && (selected.client_name ?? '') !== clientName) {
@@ -120,12 +129,11 @@
</script> </script>
<div class="picker" bind:this={root} onfocusin={() => (focused = true)} onfocusout={onFocusOut}> <div class="picker" bind:this={root} onfocusin={() => (focused = true)} onfocusout={onFocusOut}>
<div class="client-row">
<label class="client-label" for={`${inputId}-client`}>Client</label>
<select <select
id={`${inputId}-client`} id={`${inputId}-client`}
class="client-select" class="client-select"
bind:value={clientName} bind:value={clientName}
aria-label="Filter by client"
{disabled} {disabled}
> >
<option value="">All clients</option> <option value="">All clients</option>
@@ -133,7 +141,6 @@
<option value={client}>{client}</option> <option value={client}>{client}</option>
{/each} {/each}
</select> </select>
</div>
<div class="combo" role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-controls={`${inputId}-list`}> <div class="combo" role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-controls={`${inputId}-list`}>
<span class="combo-icon" aria-hidden="true"><Search size={16} strokeWidth={2.2} /></span> <span class="combo-icon" aria-hidden="true"><Search size={16} strokeWidth={2.2} /></span>
@@ -196,30 +203,23 @@
</div> </div>
<style> <style>
/* Client filter and product search sit side by side so the product is never
stacked underneath the client. They wrap to two rows only when the cell is
too narrow to keep both readable. */
.picker { .picker {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 0.4rem; align-items: stretch;
gap: 0.45rem;
min-width: 0; min-width: 0;
} }
.client-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.client-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
}
.client-select { .client-select {
flex: 1 1 auto; flex: 0 1 8.5rem;
min-width: 0; min-width: 6rem;
min-height: 40px; min-height: 48px;
padding: 0.4rem 0.55rem; padding: 0.4rem 0.55rem;
border: 1px solid var(--color-border, #d1d5db); border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.5rem; border-radius: 0.55rem;
font: inherit; font: inherit;
background: var(--color-bg-surface, #fff); background: var(--color-bg-surface, #fff);
color: var(--color-text-primary, #111827); color: var(--color-text-primary, #111827);
@@ -228,8 +228,17 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
@media (max-width: 560px) {
.picker {
flex-wrap: wrap;
}
.client-select {
flex: 1 1 100%;
}
}
.combo-icon { .combo-icon {
position: absolute; position: absolute;
left: 0.6rem; left: 0.6rem;
@@ -276,7 +285,7 @@
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; left: 0;
right: 0; right: 0;
z-index: 50; z-index: 200;
margin: 0; margin: 0;
padding: 0.25rem; padding: 0.25rem;
list-style: none; list-style: none;
+130 -10
View File
@@ -2,12 +2,20 @@ import {
BadgeDollarSign, BadgeDollarSign,
Calculator, Calculator,
ClipboardPenLine, ClipboardPenLine,
FlaskConical,
Gauge, Gauge,
Layers, Layers,
LayoutDashboard, LayoutDashboard,
Link2,
ListOrdered,
Package,
Plug,
ShieldCheck, ShieldCheck,
ShoppingCart, ShoppingCart,
TrendingUp SlidersHorizontal,
Tags,
TrendingUp,
Users
} from 'lucide-svelte'; } from 'lucide-svelte';
import type { ComponentType } from 'svelte'; import type { ComponentType } from 'svelte';
@@ -31,6 +39,18 @@ export type NavItem = {
icon: ComponentType; icon: ComponentType;
moduleKey?: string; moduleKey?: string;
badge?: string; badge?: string;
/**
* Highlight this row only on an exact pathname match instead of a prefix
* match. Needed for parent routes like `/ordering/manage` that are a prefix
* of their siblings (`/ordering/manage/products`).
*/
exact?: boolean;
/**
* Optional third-level submenu. A child with `children` renders as its own
* collapsible row inside a group (e.g. Integrations Xero). The row stays a
* link to its own `href`; a chevron toggles the nested list.
*/
children?: NavItem[];
}; };
export type FooterLink = { export type FooterLink = {
@@ -50,6 +70,14 @@ export type NavGroup = {
label: string; label: string;
icon: ComponentType; icon: ComponentType;
children: NavItem[]; children: NavItem[];
/**
* When set, the group header is itself a link (clicking it navigates here)
* while a separate chevron still toggles the child list. Used by Order
* Management: clicking the header lands on the order queue.
*/
href?: string;
/** Exact-match the header link's active state (see NavItem.exact). */
exact?: boolean;
}; };
/** The rail is a sequence of standalone items and collapsible groups. */ /** The rail is a sequence of standalone items and collapsible groups. */
@@ -96,6 +124,15 @@ export const editorItem: NavItem = {
badge: 'test' badge: 'test'
}; };
export const ingredientsEditorItem: NavItem = {
href: '/ingredients',
label: 'Ingredients Editor',
shortLabel: 'IE',
icon: FlaskConical,
moduleKey: 'products',
badge: 'test'
};
export const reportingItem: NavItem = { export const reportingItem: NavItem = {
href: '/reporting', href: '/reporting',
label: 'Reporting', label: 'Reporting',
@@ -121,6 +158,37 @@ export const orderingItem: NavItem = {
moduleKey: 'ordering' moduleKey: 'ordering'
}; };
/** Third-level submenu under Integrations. Each connected system is its own row. */
export const integrationsChildren: NavItem[] = [
{ href: '/ordering/manage/integrations/xero', label: 'Xero', shortLabel: 'XE', icon: Link2, moduleKey: 'ordering' }
];
/**
* Children of the internal "Order Management" family. The first entry points at
* the management root (`/ordering/manage`) which renders the order queue, so it
* needs `exact` matching to avoid lighting up on its sibling routes. Integrations
* is itself a parent: it links to the integrations landing and expands to its
* connected systems (Xero) as a third sidebar layer.
*/
export const orderingManageChildren: NavItem[] = [
{ href: '/ordering/manage', label: 'Orders', shortLabel: 'OQ', icon: ListOrdered, moduleKey: 'ordering', exact: true },
{ href: '/ordering/manage/products', label: 'Products', shortLabel: 'PR', icon: Package, moduleKey: 'ordering' },
{ href: '/ordering/manage/customers', label: 'Customers', shortLabel: 'CU', icon: Users, moduleKey: 'ordering' },
{ href: '/ordering/manage/pricing', label: 'Pricing', shortLabel: 'PX', icon: Tags, moduleKey: 'ordering' },
{ href: '/ordering/manage/settings', label: 'Settings', shortLabel: 'ST', icon: SlidersHorizontal, moduleKey: 'ordering' },
{ href: '/ordering/manage/integrations', label: 'Integrations', shortLabel: 'IN', icon: Plug, moduleKey: 'ordering', exact: true, children: integrationsChildren }
];
/** The collapsible Order Management family for internal staff. */
export const orderingManageGroup: NavGroup = {
id: 'ordering',
label: 'Order Management',
icon: ShoppingCart,
href: '/ordering/manage',
exact: true,
children: orderingManageChildren
};
export const workingDocumentItems: NavItem[] = [ export const workingDocumentItems: NavItem[] = [
// Mix Master remains available through the existing route and access logic, // Mix Master remains available through the existing route and access logic,
// but is temporarily hidden from the sidebar. // but is temporarily hidden from the sidebar.
@@ -140,6 +208,7 @@ export const clientNavigationItems: NavItem[] = [
productCostingItem, productCostingItem,
throughputItem, throughputItem,
editorItem, editorItem,
ingredientsEditorItem,
accessControlItem accessControlItem
]; ];
@@ -155,8 +224,14 @@ export const baseSearchItems: SearchItem[] = [
{ {
href: '/editor', href: '/editor',
label: 'Open Mix Editor', label: 'Open Mix Editor',
description: 'Edit client, product, and mix naming from one table.', description: 'Edit mix names, status, and ingredients from one table.',
keywords: 'editor products mixes clients names bulk table phf horse manning' keywords: 'editor mixes clients names status ingredients recipe table phf horse manning'
},
{
href: '/ingredients',
label: 'Open Ingredients Editor',
description: 'Curate the raw material ingredients available to mixes.',
keywords: 'ingredients editor raw materials supplier unit kg per unit catalogue mixes'
}, },
{ {
href: '/', href: '/',
@@ -219,7 +294,7 @@ export function buildClientNavEntries(visible: {
dashboard?: NavItem | null; dashboard?: NavItem | null;
costing: NavItem[]; costing: NavItem[];
throughput?: NavItem | null; throughput?: NavItem | null;
ordering?: NavItem | null; ordering?: NavEntry | null;
reporting?: NavItem | null; reporting?: NavItem | null;
}): NavEntry[] { }): NavEntry[] {
const entries: NavEntry[] = []; const entries: NavEntry[] = [];
@@ -236,7 +311,7 @@ export function buildClientNavEntries(visible: {
} }
if (visible.ordering) { if (visible.ordering) {
entries.push({ kind: 'item', item: visible.ordering }); entries.push(visible.ordering);
} }
if (visible.throughput) { if (visible.throughput) {
@@ -250,16 +325,47 @@ export function buildClientNavEntries(visible: {
return entries; return entries;
} }
/** True when any of a group's children matches the current route. */ /** True when a row or any of its nested children matches the current route. */
export function groupHasActiveChild(group: NavGroup, pathname: string) { function itemOrChildActive(item: NavItem, pathname: string): boolean {
return group.children.some((child) => matchesRoute(child.href, pathname)); if (matchesRoute(item.href, pathname, item.exact)) return true;
return item.children?.some((child) => matchesRoute(child.href, pathname, child.exact)) ?? false;
} }
export function matchesRoute(href: string, pathname: string) { /** True when any of a group's children (or grandchildren) matches the route. */
return href === '/' ? pathname === '/' : pathname.startsWith(href); export function groupHasActiveChild(group: NavGroup, pathname: string) {
return group.children.some((child) => itemOrChildActive(child, pathname));
}
export function matchesRoute(href: string, pathname: string, exact = false) {
if (href === '/') return pathname === '/';
if (exact) return pathname === href;
return pathname.startsWith(href);
}
/**
* Resolve the deepest Order Management section row for a path, descending into
* third-level submenus (Integrations Xero) so headers and breadcrumbs name the
* actual page rather than the parent. Returns null for the management root.
*/
export function findOrderingSection(pathname: string): NavItem | null {
for (const child of orderingManageChildren) {
// Check grandchildren first so a nested page (Xero) wins over its parent.
for (const grandchild of child.children ?? []) {
if (matchesRoute(grandchild.href, pathname, grandchild.exact)) return grandchild;
}
if (matchesRoute(child.href, pathname, child.exact)) return child;
}
return null;
} }
export function pageTitle(pathname: string) { export function pageTitle(pathname: string) {
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'; return clientNavigationItems.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
} }
@@ -297,6 +403,20 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
return base; return base;
} }
if (pathname.startsWith('/ordering/manage')) {
const section = findOrderingSection(pathname);
if (!section || section.href === '/ordering/manage') {
return [...crumbs, { label: 'Order Management' }];
}
const chain: Crumb[] = [...crumbs, { label: 'Order Management', href: '/ordering/manage' }];
// When the section is a nested grandchild (e.g. Xero), include its parent
// (Integrations) as an intermediate crumb.
const parent = orderingManageChildren.find((c) => c.children?.includes(section));
if (parent) chain.push({ label: parent.label, href: parent.href });
chain.push({ label: section.label });
return chain;
}
const sectionMap: Record<string, string> = { const sectionMap: Record<string, string> = {
'/raw-materials': 'Raw Materials', '/raw-materials': 'Raw Materials',
'/product-costing': 'Product Costing', '/product-costing': 'Product Costing',
+63
View File
@@ -0,0 +1,63 @@
// Shared formatting helpers for the ordering management console. Kept tiny and
// dependency-free so each split route page (orders, products, pricing, …) can
// import them instead of re-declaring the same money/label functions.
const AUD = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
/** Format a value as AUD, or an em dash when null/undefined. */
export function money(value: number | null | undefined): string {
if (value == null) return '—';
return AUD.format(value);
}
/** Turn a snake_case status/category key into Title Case for display. */
export function label(value: string): string {
return value.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Order workflow statuses, in the order they typically progress. */
export const ORDER_STATUSES = [
'submitted',
'under_review',
'confirmed',
'sent_to_xero',
'in_production',
'ready_for_pickup',
'dispatched',
'completed',
'cancelled'
] as const;
/**
* Map an order / customer status to a pill tone class so states are scannable
* at a glance: warn = needs attention, info = in flight, pos = done/active,
* danger = cancelled. Unknown values fall back to the neutral base pill.
*/
const STATUS_TONE: Record<string, string> = {
submitted: 'warn',
under_review: 'warn',
confirmed: 'pos',
completed: 'pos',
active: 'pos',
cancelled: 'danger',
disabled: 'danger',
suspended: 'danger',
sent_to_xero: 'info',
in_production: 'info',
ready_for_pickup: 'info',
dispatched: 'info'
};
export function statusTone(value: string): string {
return STATUS_TONE[value] ?? '';
}
/** Catalogue product categories. */
export const PRODUCT_CATEGORIES = [
'grains',
'premixed',
'bags',
'bulk_loads',
'custom_blends',
'services'
] as const;
+222
View File
@@ -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; }
}
+68 -19
View File
@@ -46,18 +46,20 @@
--color-text-secondary: oklch(0.45 0.008 240); --color-text-secondary: oklch(0.45 0.008 240);
--color-text-muted: oklch(0.6 0.01 240); --color-text-muted: oklch(0.6 0.01 240);
/* Sidebar: light monochrome rail with the current item shown as the /* Sidebar: deep-green rail matching the customer ordering portal
selected pill. Shared across themes so navigation stays consistent. */ (CustomerPortalShell). The current item shows as a white pill with
--sidebar-bg: oklch(0.985 0.001 240); green text. Held constant across light/dark themes so internal staff
--sidebar-hover: oklch(0.952 0.003 240); and customers see the same navigation styling. */
--sidebar-active-bg: #3290d9; --sidebar-bg: #1f3a2c;
--sidebar-active-text: var(--color-on-brand); --sidebar-hover: rgba(255, 255, 255, 0.08);
--sidebar-border: oklch(0.9 0.004 240); --sidebar-active-bg: #ffffff;
--sidebar-text: oklch(0.34 0.006 240); --sidebar-active-text: #1f3a2c;
--sidebar-text-strong: oklch(0.16 0.004 240); --sidebar-border: rgba(255, 255, 255, 0.12);
--sidebar-text-muted: oklch(0.56 0.008 240); --sidebar-text: rgba(231, 239, 233, 0.85);
--sidebar-icon: oklch(0.42 0.006 240); --sidebar-text-strong: #ffffff;
--sidebar-logo-bg: oklch(0.98 0.003 240); --sidebar-text-muted: rgba(231, 239, 233, 0.6);
--sidebar-icon: rgba(231, 239, 233, 0.8);
--sidebar-logo-bg: rgba(255, 255, 255, 0.1);
/* ── Semantic ───────────────────────────────────────────── */ /* ── Semantic ───────────────────────────────────────────── */
--color-success: oklch(0.66 0.16 162); /* emerald, cohesive with accent */ --color-success: oklch(0.66 0.16 162); /* emerald, cohesive with accent */
@@ -128,19 +130,20 @@
--color-border: oklch(0.32 0.006 240); --color-border: oklch(0.32 0.006 240);
--color-divider: oklch(0.28 0.005 240); --color-divider: oklch(0.28 0.005 240);
/* Sidebar: dark rail tuned to the content theme so it stops /* Sidebar: in dark mode the rail drops the deep-green and joins the
rendering as a bright light strip in dark mode. Active item keeps neutral dark surfaces, sitting a touch below the app canvas so it
the blue pill from light mode. */ reads as a distinct rail. The active item becomes a green-tinted pill
with bright brand text rather than the light-mode white chip. */
--sidebar-bg: oklch(0.2 0.005 240); --sidebar-bg: oklch(0.2 0.005 240);
--sidebar-hover: oklch(0.27 0.006 240); --sidebar-hover: oklch(0.27 0.006 240);
--sidebar-active-bg: #3290d9; --sidebar-active-bg: color-mix(in srgb, var(--color-brand) 22%, var(--color-bg-surface));
--sidebar-active-text: var(--color-on-brand); --sidebar-active-text: oklch(0.9 0.07 162);
--sidebar-border: oklch(0.3 0.006 240); --sidebar-border: oklch(0.3 0.006 240);
--sidebar-text: oklch(0.78 0.006 240); --sidebar-text: oklch(0.78 0.006 240);
--sidebar-text-strong: oklch(0.96 0.003 240); --sidebar-text-strong: oklch(0.96 0.003 240);
--sidebar-text-muted: oklch(0.6 0.008 240); --sidebar-text-muted: oklch(0.6 0.008 240);
--sidebar-icon: oklch(0.68 0.008 240); --sidebar-icon: oklch(0.72 0.006 240);
--sidebar-logo-bg: oklch(0.26 0.006 240); --sidebar-logo-bg: rgba(255, 255, 255, 0.08);
/* ── Text (neutral) ─────────────────────────────────────── */ /* ── Text (neutral) ─────────────────────────────────────── */
--color-text-primary: oklch(0.96 0.003 240); --color-text-primary: oklch(0.96 0.003 240);
@@ -560,3 +563,49 @@ a {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ============================================================================
Tooltip (rendered to <body> by the use:tooltip action)
----------------------------------------------------------------------------
Auto-inverts via the text/surface tokens: a near-black bubble in light mode,
a near-white bubble in dark mode readable contrast in both.
============================================================================ */
.app-tooltip {
position: fixed;
z-index: 1000;
padding: 0.36rem 0.55rem;
border-radius: 0.5rem;
background: var(--color-text-primary);
color: var(--color-bg-surface);
font-size: 0.76rem;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.005em;
white-space: normal;
overflow-wrap: break-word;
text-align: center;
width: max-content;
max-width: min(16rem, calc(100vw - 1rem));
pointer-events: none;
box-shadow: 0 6px 20px -6px rgba(0, 0, 0, 0.35), 0 2px 6px -2px rgba(0, 0, 0, 0.25);
opacity: 0;
transform: translateY(-2px);
transition: opacity 130ms ease, transform 130ms ease;
}
.app-tooltip[data-placement='top'] {
transform: translateY(2px);
}
.app-tooltip.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.app-tooltip {
transition: opacity 130ms ease;
transform: none;
}
}
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
import { ChevronDown, ChevronsUpDown, ChevronUp } from 'lucide-svelte';
import type { TableController } from './table.svelte';
let {
label,
column,
controller
}: {
label: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
controller: TableController<any>;
column: string;
} = $props();
const active = $derived(controller.sortKey === column);
</script>
<button
type="button"
class="sort-header"
class:active
aria-label={`Sort by ${label}${active ? (controller.sortDir === 'asc' ? ', ascending' : ', descending') : ''}`}
onclick={() => controller.toggleSort(column)}
>
<span>{label}</span>
{#if active}
{#if controller.sortDir === 'asc'}
<ChevronUp size={13} strokeWidth={2.6} />
{:else}
<ChevronDown size={13} strokeWidth={2.6} />
{/if}
{:else}
<ChevronsUpDown size={13} strokeWidth={2} />
{/if}
</button>
<style>
.sort-header {
display: inline-flex;
align-items: center;
gap: 0.28rem;
width: 100%;
min-height: 26px;
padding: 0;
border: 0;
background: transparent;
/* Inherit the .log-head typography (size/weight/transform/letter-spacing). */
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
text-align: left;
cursor: pointer;
user-select: none;
}
.sort-header :global(svg) {
flex-shrink: 0;
opacity: 0.4;
transition: opacity 120ms ease;
}
.sort-header:hover {
color: var(--color-text-primary);
}
.sort-header:hover :global(svg) {
opacity: 0.7;
}
.sort-header.active {
color: var(--color-brand);
}
.sort-header.active :global(svg) {
opacity: 1;
}
.sort-header:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
border-radius: 0.3rem;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
// Shared client-side table controller: sorting + pagination over an already
// filtered row set. Both the Mix Editor and Ingredients Editor feed their
// filtered `visibleRows` in and render `rows` out, so the two surfaces stay
// behaviourally identical. Keeping the page math here (rather than ad-hoc
// `$effect`s per page) means navigation can't get clamped back to page 1.
export type SortDir = 'asc' | 'desc';
export type SortValue = string | number | boolean | null | undefined;
export type Accessor<T> = (row: T) => SortValue;
const PAGE_SIZES = [10, 25, 50, 100] as const;
function isEmpty(value: SortValue): boolean {
return value === null || value === undefined || value === '';
}
function compareNonEmpty(a: SortValue, b: SortValue): number {
if (typeof a === 'number' && typeof b === 'number') return a - b;
if (typeof a === 'boolean' && typeof b === 'boolean') {
return a === b ? 0 : a ? -1 : 1;
}
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
export class TableController<T> {
readonly pageSizes = PAGE_SIZES;
page = $state(1);
pageSize = $state(25);
sortKey = $state<string | null>(null);
sortDir = $state<SortDir>('asc');
private readonly source: () => readonly T[];
private readonly accessors: Record<string, Accessor<T>>;
constructor(source: () => readonly T[], accessors: Record<string, Accessor<T>> = {}) {
this.source = source;
this.accessors = accessors;
}
readonly sorted = $derived.by<readonly T[]>(() => {
const rows = this.source();
const accessor = this.sortKey ? this.accessors[this.sortKey] : undefined;
if (!accessor) return rows;
const dir = this.sortDir === 'asc' ? 1 : -1;
// Empties always sort last, regardless of direction.
return [...rows].sort((a, b) => {
const av = accessor(a);
const bv = accessor(b);
const aEmpty = isEmpty(av);
const bEmpty = isEmpty(bv);
if (aEmpty || bEmpty) {
if (aEmpty && bEmpty) return 0;
return aEmpty ? 1 : -1;
}
return compareNonEmpty(av, bv) * dir;
});
});
readonly total = $derived(this.sorted.length);
readonly totalPages = $derived(Math.max(1, Math.ceil(this.total / this.pageSize)));
// The page we actually show. Derived clamping means shrinking the result set
// (via filters or a larger page size) can never strand the view on an empty page.
readonly currentPage = $derived(Math.min(Math.max(1, this.page), this.totalPages));
readonly pageStart = $derived(this.total === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1);
readonly pageEnd = $derived(Math.min(this.total, this.currentPage * this.pageSize));
readonly rows = $derived(this.sorted.slice(this.pageStart === 0 ? 0 : this.pageStart - 1, this.pageEnd));
readonly canPrev = $derived(this.currentPage > 1);
readonly canNext = $derived(this.currentPage < this.totalPages);
toggleSort(key: string): void {
if (!this.accessors[key]) return;
if (this.sortKey === key) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortDir = 'asc';
}
this.page = 1;
}
setPage(next: number): void {
this.page = Math.min(Math.max(1, next), this.totalPages);
}
next(): void {
this.setPage(this.currentPage + 1);
}
prev(): void {
this.setPage(this.currentPage - 1);
}
reset(): void {
this.page = 1;
}
}
+92
View File
@@ -99,6 +99,7 @@ export type MixCalculatorLine = {
required_kg: number; required_kg: number;
mix_percentage: number; mix_percentage: number;
unit: string; unit: string;
rounding_decimals?: number;
sort_order: number; sort_order: number;
}; };
@@ -306,6 +307,35 @@ export type EditorMixUpdateInput = {
client_name?: string; client_name?: string;
name?: string; name?: string;
notes?: string | null; notes?: string | null;
visible?: boolean;
};
export type EditorMixRow = {
id: number;
tenant_id: string;
client_name: string;
name: string;
visible: boolean;
product_count: number;
visible_product_count: number;
notes: string | null;
};
export type EditorMixIngredient = {
id: number;
raw_material_id: number;
raw_material_name: string;
quantity_kg: number;
notes: string | null;
};
export type EditorMixFormula = {
id: number;
tenant_id: string;
client_name: string;
name: string;
ingredients: EditorMixIngredient[];
total_kg: number;
}; };
export type EditorProductIngredient = { export type EditorProductIngredient = {
@@ -328,6 +358,32 @@ export type EditorProductFormula = {
total_kg: number; total_kg: number;
}; };
export type EditorIngredientRow = {
id: number;
name: string;
supplier: string | null;
unit_of_measure: string;
kg_per_unit: number;
status: string;
rounding_decimals: number;
notes: string | null;
cost_per_kg: number | null;
usage_count: number;
created_at: string;
};
export type EditorIngredientCreateInput = {
name: string;
supplier?: string | null;
unit_of_measure: string;
kg_per_unit: number;
status?: string;
rounding_decimals?: number;
notes?: string | null;
};
export type EditorIngredientUpdateInput = Partial<EditorIngredientCreateInput>;
export type Scenario = { export type Scenario = {
id: number; id: number;
name: string; name: string;
@@ -616,6 +672,15 @@ export type ThroughputEntryCreateInput = {
notes?: string | null; notes?: string | null;
}; };
export type ThroughputEntryUpdateInput = Partial<ThroughputEntryCreateInput>;
export type ThroughputImportResult = {
entries_imported: number;
entries_skipped: number;
products_created: number;
errors: string[];
};
export type ThroughputEntryListParams = { export type ThroughputEntryListParams = {
date_from?: string; date_from?: string;
date_to?: string; date_to?: string;
@@ -745,6 +810,8 @@ export type OrderingCustomer = {
user_count: number; user_count: number;
price_list_id: number | null; price_list_id: number | null;
discount_percent: number; discount_percent: number;
xero_contact_id?: string | null;
xero_contact_name?: string | null;
created_at: string; created_at: string;
}; };
@@ -793,6 +860,7 @@ export type OrderingNotificationSettings = {
export type XeroStatus = { export type XeroStatus = {
connection: { configured: boolean; mode: string; base_url: string; checked_at: string; missing_env: string[] }; connection: { configured: boolean; mode: string; base_url: string; checked_at: string; missing_env: string[] };
contact_links: { linked: number; total: number; unlinked: number };
recent_syncs: { recent_syncs: {
id: number; id: number;
order_id: number; order_id: number;
@@ -802,3 +870,27 @@ export type XeroStatus = {
created_at: string; created_at: string;
}[]; }[];
}; };
export type XeroContact = {
contact_id: string;
name: string;
email: string | null;
status: string;
};
export type XeroContactList = {
contacts: XeroContact[];
stubbed: boolean;
};
export type XeroContactLinkRow = {
customer_id: number;
customer_name: string;
client_code: string;
linked: boolean;
xero_contact_id: string | null;
xero_contact_name: string | null;
xero_contact_email: string | null;
last_synced_at: string | null;
suggested_contact_id: string | null;
};
+1
View File
@@ -264,6 +264,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
if (pathname.startsWith('/product-costing')) return canOpenProductCosting(session); if (pathname.startsWith('/product-costing')) return canOpenProductCosting(session);
if (pathname.startsWith('/products')) return canOpenProducts(session); if (pathname.startsWith('/products')) return canOpenProducts(session);
if (pathname.startsWith('/editor')) return canOpenEditor(session); if (pathname.startsWith('/editor')) return canOpenEditor(session);
if (pathname.startsWith('/ingredients')) return canOpenEditor(session);
if (pathname.startsWith('/scenarios')) return canOpenScenarios(session); if (pathname.startsWith('/scenarios')) return canOpenScenarios(session);
if (pathname.startsWith('/reporting')) return canOpenReporting(session); if (pathname.startsWith('/reporting')) return canOpenReporting(session);
if (pathname.startsWith('/settings')) return canOpenSettings(session); if (pathname.startsWith('/settings')) return canOpenSettings(session);
+9 -1
View File
@@ -1,7 +1,7 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session'; import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenDashboard, getWorkspaceHomeHref } from '$lib/workspace-access'; import { canOpenDashboard, getWorkspaceHomeHref, isCustomerPortalSession } from '$lib/workspace-access';
import type { DashboardSummary } from '$lib/types'; import type { DashboardSummary } from '$lib/types';
const EMPTY_SUMMARY: DashboardSummary = { const EMPTY_SUMMARY: DashboardSummary = {
@@ -22,6 +22,14 @@ export function load({ fetch }) {
} }
const session = getStoredClientSession(); const session = getStoredClientSession();
// B2B ordering customers must never land on (or flash) the internal
// dashboard — even if their account happens to carry the dashboard module.
// Send them straight to their catalogue before any dashboard data loads.
if (isCustomerPortalSession(session)) {
throw redirect(307, '/ordering');
}
if (!canOpenDashboard(session)) { if (!canOpenDashboard(session)) {
throw redirect(307, getWorkspaceHomeHref(session)); throw redirect(307, getWorkspaceHomeHref(session));
} }
+91 -227
View File
@@ -2,17 +2,22 @@
import { api } from '$lib/api'; import { api } from '$lib/api';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte'; 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 { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let { data } = $props(); let { data } = $props();
type EditableRow = EditorProductRow & { type EditableRow = EditorMixRow & {
draft_product_name: string;
draft_mix_name: string; draft_mix_name: string;
draft_sale_type: string;
draft_unit_of_measure: string;
draft_visible: boolean; draft_visible: boolean;
}; };
@@ -25,24 +30,17 @@
let rows = $state<EditableRow[]>([]); let rows = $state<EditableRow[]>([]);
let query = $state(''); let query = $state('');
let productFilter = $state('');
let clientFilter = $state('all'); let clientFilter = $state('all');
let visibilityFilter = $state<'all' | 'visible' | 'hidden'>('visible'); 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 savingKey = $state<string | null>(null);
let expandedProductId = $state<number | null>(null); let expandedMixId = $state<number | null>(null);
let activeFormula = $state<EditorProductFormula | null>(null); let activeFormula = $state<EditorMixFormula | null>(null);
let ingredientDrafts = $state<DraftIngredient[]>([]); let ingredientDrafts = $state<DraftIngredient[]>([]);
function toEditableRow(row: EditorProductRow): EditableRow { function toEditableRow(row: EditorMixRow): EditableRow {
return { return {
...row, ...row,
draft_product_name: row.name, draft_mix_name: row.name,
draft_mix_name: row.mix_name,
draft_sale_type: row.sale_type,
draft_unit_of_measure: row.unit_of_measure,
draft_visible: row.visible draft_visible: row.visible
}; };
} }
@@ -53,7 +51,7 @@
} }
}); });
function ingredientToDraft(ingredient: EditorProductIngredient): DraftIngredient { function ingredientToDraft(ingredient: EditorMixIngredient): DraftIngredient {
return { return {
id: ingredient.id, id: ingredient.id,
raw_material_id: ingredient.raw_material_id, raw_material_id: ingredient.raw_material_id,
@@ -71,68 +69,32 @@
}; };
} }
function loadIngredientDrafts(formula: EditorProductFormula) { function loadIngredientDrafts(formula: EditorMixFormula) {
activeFormula = formula; activeFormula = formula;
ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()]; 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) { 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)); 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) { async function saveRow(row: EditableRow) {
if (!rowDirty(row)) return; if (!rowDirty(row)) return;
savingKey = `row:${row.id}`; savingKey = `row:${row.id}`;
try { try {
if (productDirty(row)) { const payload: EditorMixUpdateInput = {};
applyProductUpdate( if (row.draft_mix_name.trim() !== row.name) payload.name = row.draft_mix_name.trim();
await api.updateEditorProduct(row.id, { if (row.draft_visible !== row.visible) payload.visible = row.draft_visible;
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 current = rows.find((candidate) => candidate.id === row.id) ?? row; applyMixUpdate(await api.updateEditorMix(row.id, payload));
if (mixDirty(current)) { toast.success('Mix saved');
applyMixUpdate(
await api.updateEditorMix(current.mix_id, {
name: current.draft_mix_name.trim()
})
);
}
toast.success('Row saved');
} catch (error) { } 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 { } finally {
savingKey = null; savingKey = null;
} }
@@ -143,20 +105,20 @@
} }
async function toggleIngredients(row: EditableRow) { async function toggleIngredients(row: EditableRow) {
if (expandedProductId === row.id) { if (expandedMixId === row.id) {
expandedProductId = null; expandedMixId = null;
activeFormula = null; activeFormula = null;
ingredientDrafts = []; ingredientDrafts = [];
return; return;
} }
expandedProductId = row.id; expandedMixId = row.id;
savingKey = `product-load:${row.id}`; savingKey = `mix-load:${row.id}`;
try { try {
loadIngredientDrafts(await api.editorProductFormula(row.id)); loadIngredientDrafts(await api.editorMixFormula(row.id));
} catch (error) { } catch (error) {
expandedProductId = null; expandedMixId = null;
toast.error(error instanceof Error ? error.message : 'Unable to load ingredients'); toast.error(error instanceof Error ? error.message : 'Unable to load ingredients');
} finally { } finally {
savingKey = null; savingKey = null;
@@ -177,7 +139,7 @@
.map((row) => row.raw_material_id) .map((row) => row.raw_material_id)
.filter((rawMaterialId): rawMaterialId is number => rawMaterialId !== null); .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 (!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.']; 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; if (!activeFormula) return;
savingKey = `product-save:${activeFormula.id}`; savingKey = `mix-save:${activeFormula.id}`;
try { try {
const cleanRows = ingredientDrafts.map((row) => ({ const cleanRows = ingredientDrafts.map((row) => ({
@@ -212,14 +174,14 @@
for (const ingredient of activeFormula.ingredients) { for (const ingredient of activeFormula.ingredients) {
const draft = cleanRows.find((row) => row.id === ingredient.id); const draft = cleanRows.find((row) => row.id === ingredient.id);
if (!keptIds.has(ingredient.id) || (draft && draft.raw_material_id !== ingredient.raw_material_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) { for (const row of cleanRows) {
const original = row.id === null ? null : originalById.get(row.id); const original = row.id === null ? null : originalById.get(row.id);
if (!original || original.raw_material_id !== row.raw_material_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, raw_material_id: row.raw_material_id,
quantity_kg: row.quantity_kg, quantity_kg: row.quantity_kg,
notes: row.notes notes: row.notes
@@ -228,14 +190,14 @@
} }
if (original.quantity_kg !== row.quantity_kg || (original.notes ?? null) !== row.notes) { 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, quantity_kg: row.quantity_kg,
notes: row.notes notes: row.notes
}); });
} }
} }
loadIngredientDrafts(await api.editorProductFormula(activeFormula.id)); loadIngredientDrafts(await api.editorMixFormula(activeFormula.id));
toast.success('Ingredients saved'); toast.success('Ingredients saved');
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to save ingredients'); toast.error(error instanceof Error ? error.message : 'Unable to save ingredients');
@@ -250,25 +212,15 @@
function clearFilters() { function clearFilters() {
query = ''; query = '';
productFilter = '';
clientFilter = 'all'; clientFilter = 'all';
visibilityFilter = 'visible'; visibilityFilter = 'visible';
packFilter = 'all'; table.reset();
page = 1;
} }
function setVisibilityFilter(value: 'all' | 'visible' | 'hidden') { function setVisibilityFilter(value: 'all' | 'visible' | 'hidden') {
visibilityFilter = value; visibilityFilter = value;
} }
function previousPage() {
page = Math.max(1, page - 1);
}
function nextPage() {
page = Math.min(totalPages, page + 1);
}
const clientOptions = $derived( const clientOptions = $derived(
Array.from( Array.from(
new Set( new Set(
@@ -286,57 +238,38 @@
const visibleRows = $derived( const visibleRows = $derived(
rows.filter((row) => { rows.filter((row) => {
const term = query.trim().toLowerCase(); const term = query.trim().toLowerCase();
const productTerm = productFilter.trim().toLowerCase();
const clientMatches = clientFilter === 'all' || row.client_name === clientFilter; const clientMatches = clientFilter === 'all' || row.client_name === clientFilter;
const productMatches = !productTerm || row.name.toLowerCase().includes(productTerm);
const visibilityMatches = const visibilityMatches =
visibilityFilter === 'all' || visibilityFilter === 'all' ||
(visibilityFilter === 'visible' && row.visible) || (visibilityFilter === 'visible' && row.visible) ||
(visibilityFilter === 'hidden' && !row.visible); (visibilityFilter === 'hidden' && !row.visible);
const packMatches = packFilter === 'all' || row.sale_type === packFilter;
if (!clientMatches) return false; if (!clientMatches) return false;
if (!productMatches || !visibilityMatches || !packMatches) return false; if (!visibilityMatches) return false;
if (!term) return true; if (!term) return true;
return [ return [row.client_name, row.name].join(' ').toLowerCase().includes(term);
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);
}) })
); );
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 dirtyCount = $derived(rows.filter(rowDirty).length);
const uniqueMixCount = $derived(new Set(visibleRows.map((row) => row.mix_id)).size); const filtersActive = $derived(Boolean(query.trim() || clientFilter !== 'all' || visibilityFilter !== 'visible'));
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 ingredientTotalKg = $derived( const ingredientTotalKg = $derived(
ingredientDrafts.reduce((sum, ingredient) => sum + Number(ingredient.quantity_kg || 0), 0) ingredientDrafts.reduce((sum, ingredient) => sum + Number(ingredient.quantity_kg || 0), 0)
); );
// Jump back to the first page whenever the filtered set changes.
$effect(() => { $effect(() => {
query; query;
productFilter;
clientFilter; clientFilter;
visibilityFilter; visibilityFilter;
packFilter; table.pageSize;
pageSize; table.reset();
page = 1;
});
$effect(() => {
if (page > totalPages) page = totalPages;
}); });
$effect(() => { $effect(() => {
@@ -356,8 +289,8 @@
<ListFilter size={16} strokeWidth={1.8} /> <ListFilter size={16} strokeWidth={1.8} />
</div> </div>
<div class="rail-identity-text"> <div class="rail-identity-text">
<p class="identity-name">Filter products</p> <p class="identity-name">Filter mixes</p>
<p class="identity-role">{visibleRows.length} matching rows</p> <p class="identity-role">{visibleRows.length} matching mixes</p>
</div> </div>
</div> </div>
@@ -366,7 +299,7 @@
<span>Search</span> <span>Search</span>
<div class="search-input"> <div class="search-input">
<Search size={17} strokeWidth={2.2} /> <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> </div>
</label> </label>
@@ -389,21 +322,6 @@
</select> </select>
</label> </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} {#if filtersActive}
<button type="button" class="clear-button rail-clear" onclick={clearFilters}> <button type="button" class="clear-button rail-clear" onclick={clearFilters}>
<X size={16} strokeWidth={2.4} /> Clear filters <X size={16} strokeWidth={2.4} /> Clear filters
@@ -418,18 +336,14 @@
<div class="editor-status"> <div class="editor-status">
<span> <span>
<strong>Mix Editor</strong> <strong>Mix Editor</strong>
<small>{visibleRows.length} products across {uniqueMixCount} mixes</small> <small>{visibleRows.length} mixes</small>
</span> </span>
</div> </div>
<dl class="facts"> <dl class="facts">
<div class="fact">
<dt>Products</dt>
<dd>{visibleRows.length}</dd>
</div>
<div class="fact"> <div class="fact">
<dt>Mixes</dt> <dt>Mixes</dt>
<dd>{uniqueMixCount}</dd> <dd>{visibleRows.length}</dd>
</div> </div>
<div class="fact"> <div class="fact">
<dt>Unsaved</dt> <dt>Unsaved</dt>
@@ -438,76 +352,47 @@
</dl> </dl>
</div> </div>
<div class="pagination-bar" aria-label="Product table pagination"> <div class="pagination-bar" aria-label="Mix table pagination">
<span>{pageStart}-{pageEnd} of {visibleRows.length}</span> <span>{table.pageStart}-{table.pageEnd} of {table.total}</span>
<label class="page-size"> <label class="page-size">
<span>Rows</span> <span>Rows</span>
<select bind:value={pageSize}> <select bind:value={table.pageSize}>
<option value={10}>10</option> {#each table.pageSizes as size}
<option value={25}>25</option> <option value={size}>{size}</option>
<option value={50}>50</option> {/each}
<option value={100}>100</option>
</select> </select>
</label> </label>
<div class="page-controls"> <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} /> <ChevronLeft size={16} strokeWidth={2.4} />
</button> </button>
<span>Page {Math.min(page, totalPages)} of {totalPages}</span> <span>Page {table.currentPage} of {table.totalPages}</span>
<button type="button" class="clear-button icon-button" disabled={page >= totalPages} onclick={nextPage} aria-label="Next page"> <button type="button" class="clear-button icon-button" disabled={!table.canNext} onclick={() => table.next()} aria-label="Next page">
<ChevronRight size={16} strokeWidth={2.4} /> <ChevronRight size={16} strokeWidth={2.4} />
</button> </button>
</div> </div>
</div> </div>
<div class="log"> <div class="log">
<div class="log-head" aria-hidden="true"> <div class="log-head">
<span>Client</span> <SortHeader label="Client" column="client_name" controller={table} />
<span>ID</span> <SortHeader label="Mix" column="name" controller={table} />
<span>Product</span> <SortHeader label="Status" column="visible" controller={table} />
<span>Mix</span>
<span>Pack</span>
<span>Unit / Bag</span>
<span>Status</span>
<span>Actions</span> <span>Actions</span>
</div> </div>
{#each paginatedRows as row (row.id)} {#each table.rows as row (row.id)}
<div class="row" class:edited={rowDirty(row)}> <div class="row" class:edited={rowDirty(row)}>
<div class="client-cell"> <div class="client-cell">
<span class="cell-label">Client</span> <span class="cell-label">Client</span>
<span class="readonly-value">{row.client_name}</span> <span class="readonly-value">{row.client_name}</span>
</div> </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"> <div class="mix-cell">
<span class="cell-label">Mix</span> <span class="cell-label">Mix</span>
<input bind:value={row.draft_mix_name} aria-label="Mix name" /> <input bind:value={row.draft_mix_name} aria-label="Mix name" />
</div> </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"> <div class="status-cell">
<span class="cell-label">Status</span> <span class="cell-label">Status</span>
<label class="status-toggle" class:on={row.draft_visible}> <label class="status-toggle" class:on={row.draft_visible}>
@@ -519,11 +404,11 @@
<div class="row-actions"> <div class="row-actions">
<button class="clear-button" type="button" onclick={() => toggleIngredients(row)}> <button class="clear-button" type="button" onclick={() => toggleIngredients(row)}>
<FlaskConical size={16} strokeWidth={2.2} /> <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>
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}> <button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
<Save size={16} strokeWidth={2.4} /> <Save size={16} strokeWidth={2.4} />
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save row'} {savingKey === `row:${row.id}` ? 'Saving...' : 'Save mix'}
</button> </button>
{#if rowDirty(row)} {#if rowDirty(row)}
<button class="link-button" type="button" onclick={() => resetRow(row)}>Reset</button> <button class="link-button" type="button" onclick={() => resetRow(row)}>Reset</button>
@@ -531,7 +416,7 @@
</div> </div>
</div> </div>
{#if expandedProductId === row.id} {#if expandedMixId === row.id}
<div class="ingredient-panel" transition:fade={{ duration: 120 }}> <div class="ingredient-panel" transition:fade={{ duration: 120 }}>
<div class="ingredient-head"> <div class="ingredient-head">
<div> <div>
@@ -570,15 +455,15 @@
<div class="ingredient-footer"> <div class="ingredient-footer">
<button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button> <button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button>
<button class="apply-button" type="button" disabled={savingKey === `product-save:${row.id}`} onclick={saveIngredients}> <button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}`} onclick={saveIngredients}>
{savingKey === `product-save:${row.id}` ? 'Saving...' : 'Save ingredients'} {savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
</button> </button>
</div> </div>
</div> </div>
{/if} {/if}
{:else} {:else}
<div class="empty"> <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> <button type="button" class="clear-button" onclick={clearFilters}>Clear filters</button>
</div> </div>
{/each} {/each}
@@ -593,7 +478,7 @@
gap: 0.9rem; gap: 0.9rem;
min-height: 100%; min-height: 100%;
padding: 1rem 1.15rem 2rem; padding: 1rem 1.15rem 2rem;
background: #e8eee9; background: var(--color-bg-app);
} }
:global(.secondary-rail-layout) { :global(.secondary-rail-layout) {
@@ -602,7 +487,7 @@
:global(.secondary-rail-layout-panel), :global(.secondary-rail-layout-panel),
:global(.secondary-rail-layout-content) { :global(.secondary-rail-layout-content) {
background: #e8eee9; background: var(--color-bg-app);
} }
.filter-rail { .filter-rail {
@@ -613,7 +498,7 @@
gap: 0.25rem; gap: 0.25rem;
height: 100%; height: 100%;
min-height: calc(100vh - 8.5rem); 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); border-right: 1px solid var(--line);
overflow-y: auto; overflow-y: auto;
} }
@@ -621,7 +506,7 @@
.rail-label { .rail-label {
margin: 0; margin: 0;
padding: 1rem 1rem 0.15rem; padding: 1rem 1rem 0.15rem;
color: color-mix(in srgb, var(--muted) 88%, #a3aea7); color: var(--color-text-muted);
font-size: 0.64rem; font-size: 0.64rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.14em; letter-spacing: 0.14em;
@@ -643,10 +528,10 @@
flex-shrink: 0; flex-shrink: 0;
width: 2.15rem; width: 2.15rem;
height: 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%; border-radius: 50%;
background: color-mix(in srgb, var(--panel) 80%, #edf2ee); background: var(--color-brand-tint);
color: #6b786f; color: var(--color-brand);
} }
.rail-identity-text { .rail-identity-text {
@@ -659,7 +544,7 @@
} }
.identity-name { .identity-name {
color: #526059; color: var(--color-text-primary);
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
@@ -668,7 +553,7 @@
} }
.identity-role { .identity-role {
color: #8a9790; color: var(--color-text-muted);
font-size: 0.72rem; font-size: 0.72rem;
} }
@@ -690,7 +575,7 @@
flex-wrap: wrap; flex-wrap: wrap;
padding: 0.9rem 1.1rem; padding: 0.9rem 1.1rem;
background: var(--color-brand-tint); 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; border-radius: 0.9rem;
} }
@@ -784,7 +669,7 @@
} }
.apply-button { .apply-button {
color: #fbfdfa; color: var(--color-on-brand);
background: var(--color-brand); background: var(--color-brand);
border: 1px solid var(--color-brand); border: 1px solid var(--color-brand);
} }
@@ -969,13 +854,9 @@
.row { .row {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
minmax(150px, 0.9fr)
minmax(72px, 0.32fr)
minmax(210px, 1.35fr)
minmax(170px, 1fr) minmax(170px, 1fr)
minmax(105px, 0.55fr) minmax(220px, 1.6fr)
minmax(130px, 0.7fr) minmax(110px, 0.5fr)
minmax(78px, 0.34fr)
minmax(198px, auto); minmax(198px, auto);
gap: 0.55rem; gap: 0.55rem;
align-items: center; align-items: center;
@@ -1002,7 +883,7 @@
} }
.row:hover { .row:hover {
background: #fafbfc; background: var(--color-surface-hover);
} }
.row.edited { .row.edited {
@@ -1021,29 +902,12 @@
white-space: nowrap; 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 { .cell-label {
display: none; display: none;
} }
.client-cell, .client-cell,
.id-cell,
.product-cell,
.mix-cell, .mix-cell,
.pack-cell,
.unit-cell,
.status-cell { .status-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1102,7 +966,7 @@
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
padding: 0.85rem; padding: 0.85rem;
background: #f8fbf8; background: var(--panel-soft);
border-bottom: 1px solid var(--color-divider); border-bottom: 1px solid var(--color-divider);
} }
@@ -1146,8 +1010,8 @@
} }
.remove-button { .remove-button {
color: #8a1622; color: var(--color-error);
border-color: #e2a8af; border-color: color-mix(in srgb, var(--color-error) 38%, var(--color-border));
} }
.ingredient-footer { .ingredient-footer {
+1 -1
View File
@@ -15,7 +15,7 @@ export async function load({ fetch }) {
try { try {
const [rows, rawMaterials] = await Promise.all([ const [rows, rawMaterials] = await Promise.all([
api.editorProducts({ limit: 1000 }, fetch), api.editorMixes({ limit: 1000 }, fetch),
api.rawMaterials(fetch) api.rawMaterials(fetch)
]); ]);
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { ingredients: [] };
}
const session = getStoredClientSession();
if (!canOpenEditor(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const ingredients = await api.editorIngredients(fetch);
return { ingredients };
} catch {
return { ingredients: [] };
}
}
@@ -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 {};
}
+27 -461
View File
@@ -1,59 +1,16 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api } from '$lib/api';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import type { import { money, label, statusTone, ORDER_STATUSES } from '$lib/ordering/format';
CatalogueProduct, import type { Order } from '$lib/types';
CustomerPricing,
CustomerVisibilityRow,
Order,
OrderingCustomer,
OrderingCustomerUser,
OrderingNotificationSettings,
XeroStatus
} from '$lib/types';
let { data } = $props(); let { data } = $props();
type Tab = 'orders' | 'products' | 'customers' | 'settings';
let tab = $state<Tab>('orders');
// Mutable local copies of the loader data (refresh helpers reassign these).
// Seeded from `data` via an effect so navigation re-syncs without the
// "only captures the initial value" warning.
let orders = $state<Order[]>([]); let orders = $state<Order[]>([]);
let products = $state<CatalogueProduct[]>([]);
let customers = $state<OrderingCustomer[]>([]);
let xero = $state<XeroStatus | null>(null);
$effect(() => { $effect(() => {
orders = data.orders ?? []; orders = data.orders ?? [];
products = data.products ?? [];
customers = data.customers ?? [];
xero = data.xero ?? null;
}); });
const STATUSES = [
'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 selectedOrder = $state<Order | null>(null);
let statusChoice = $state(''); let statusChoice = $state('');
@@ -65,6 +22,10 @@
toast.error(e instanceof Error ? e.message : 'Could not load order.'); toast.error(e instanceof Error ? e.message : 'Could not load order.');
} }
} }
function closeOrder() {
selectedOrder = null;
statusChoice = '';
}
async function refreshOrders() { async function refreshOrders() {
try { try {
orders = await api.orderingAdmin.orders(); orders = await api.orderingAdmin.orders();
@@ -112,214 +73,23 @@
toast.error(e instanceof Error ? e.message : 'Could not reopen.'); toast.error(e instanceof Error ? e.message : 'Could not reopen.');
} }
} }
// --- Products -------------------------------------------------------------
let newProduct = $state<Record<string, any>>({ name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true });
async function refreshProducts() {
try {
products = await api.orderingAdmin.products();
} catch {}
}
async function createProduct() {
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
try {
await api.orderingAdmin.createProduct({ ...newProduct, base_price: newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price) });
toast.success('Product created.');
newProduct = { name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true };
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create product.');
}
}
async function toggleProductActive(p: CatalogueProduct) {
try {
await api.orderingAdmin.updateProduct(p.id, { active: !p.active });
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function saveProductPrice(p: CatalogueProduct, value: string) {
try {
await api.orderingAdmin.updateProduct(p.id, { base_price: value === '' ? null : Number(value) });
toast.success('Base price updated.');
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
// --- Customers ------------------------------------------------------------
let newCustomer = $state({ name: '', client_code: '' });
let selectedCustomer = $state<OrderingCustomer | null>(null);
let custUsers = $state<OrderingCustomerUser[]>([]);
let custPricing = $state<CustomerPricing | null>(null);
let custVisibility = $state<CustomerVisibilityRow[]>([]);
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
let discountInput = $state(0);
let newPrice = $state<Record<string, any>>({ product_id: '', unit_price: '', rule_type: 'fixed' });
async function refreshCustomers() {
try {
customers = await api.orderingAdmin.customers();
} catch {}
}
async function createCustomer() {
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
try {
await api.orderingAdmin.createCustomer(newCustomer);
toast.success('Customer created.');
newCustomer = { name: '', client_code: '' };
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
}
}
async function openCustomer(c: OrderingCustomer) {
selectedCustomer = c;
discountInput = c.discount_percent;
try {
[custUsers, custPricing, custVisibility] = await Promise.all([
api.orderingAdmin.customerUsers(c.id),
api.orderingAdmin.pricing(c.id),
api.orderingAdmin.visibility(c.id)
]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load customer.');
}
}
async function toggleCustomerStatus(c: OrderingCustomer) {
try {
const updated = await api.orderingAdmin.updateCustomer(c.id, { status: c.status === 'active' ? 'disabled' : 'active' });
toast.success(`Customer ${updated.status}.`);
await refreshCustomers();
if (selectedCustomer?.id === c.id) selectedCustomer = updated;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function addUser() {
if (!selectedCustomer) return;
if (!newUser.full_name || !newUser.email) return toast.error('Name and email required.');
try {
await api.orderingAdmin.createCustomerUser(selectedCustomer.id, newUser);
toast.success('User invited.');
newUser = { full_name: '', email: '', role: 'buyer' };
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not add user.');
}
}
async function toggleUserStatus(u: OrderingCustomerUser) {
if (!selectedCustomer) return;
try {
const next = u.status === 'suspended' ? 'active' : 'suspended';
await api.orderingAdmin.updateCustomerUser(selectedCustomer.id, u.id, { status: next });
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function saveDiscount() {
if (!selectedCustomer) return;
try {
custPricing = await api.orderingAdmin.setAssignment(selectedCustomer.id, { price_list_id: custPricing?.price_list_id ?? null, discount_percent: Number(discountInput) });
toast.success('Discount saved.');
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save discount.');
}
}
async function addProductPrice() {
if (!selectedCustomer || !newPrice.product_id) return toast.error('Choose a product.');
try {
custPricing = await api.orderingAdmin.setProductPrice(selectedCustomer.id, {
product_id: Number(newPrice.product_id),
unit_price: newPrice.rule_type === 'quote' || newPrice.unit_price === '' ? null : Number(newPrice.unit_price),
rule_type: newPrice.rule_type
});
toast.success('Customer price saved.');
newPrice = { product_id: '', unit_price: '', rule_type: 'fixed' };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save price.');
}
}
async function removeProductPrice(productId: number) {
if (!selectedCustomer) return;
try {
await api.orderingAdmin.deleteProductPrice(selectedCustomer.id, productId);
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not remove price.');
}
}
async function toggleVisibility(row: CustomerVisibilityRow) {
if (!selectedCustomer) return;
try {
await api.orderingAdmin.setVisibility(selectedCustomer.id, { product_id: row.product_id, visible: !row.visible });
custVisibility = await api.orderingAdmin.visibility(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
function productName(id: number) {
return products.find((p) => p.id === id)?.name ?? `#${id}`;
}
// --- Settings -------------------------------------------------------------
let settings = $state<OrderingNotificationSettings | null>(null);
async function loadSettings() {
try {
settings = await api.orderingAdmin.notificationSettings();
xero = await api.orderingAdmin.xeroStatus();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load settings.');
}
}
async function saveSettings() {
if (!settings) return;
try {
settings = await api.orderingAdmin.updateNotificationSettings(settings);
toast.success('Settings saved.');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save settings.');
}
}
$effect(() => {
if (tab === 'settings' && !settings) loadSettings();
});
</script> </script>
<div class="admin-ordering"> <section class="surface-card">
<header> <div class="card-head">
<p class="eyebrow">Ordering</p> <h2>Orders ({orders.length})</h2>
<h1>Order management</h1> </div>
</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>
{#if !orders.length} {#if !orders.length}
<p class="empty">No submitted orders.</p> <p class="empty">No submitted orders.</p>
{:else} {:else}
<table> <table class="clickable">
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Subtotal</th><th>Xero</th></tr></thead> <thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Subtotal</th><th>Xero</th></tr></thead>
<tbody> <tbody>
{#each orders as o (o.id)} {#each orders as o (o.id)}
<tr class:selected={selectedOrder?.id === o.id} onclick={() => openOrder(o)}> <tr class:selected={selectedOrder?.id === o.id} onclick={() => openOrder(o)}>
<td>{o.order_number ?? `#${o.id}`}</td> <td>{o.order_number ?? `#${o.id}`}</td>
<td>{o.customer_name}</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.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
<td>{o.xero_status ?? '—'}</td> <td>{o.xero_status ?? '—'}</td>
</tr> </tr>
@@ -327,10 +97,19 @@
</tbody> </tbody>
</table> </table>
{/if} {/if}
</section> </section>
{#if selectedOrder} {#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> <h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
<p class="muted">{selectedOrder.customer_name} · {label(selectedOrder.status)} · PO {selectedOrder.purchase_order_number ?? '—'}</p> <p class="muted">{selectedOrder.customer_name} · {label(selectedOrder.status)} · PO {selectedOrder.purchase_order_number ?? '—'}</p>
<table class="lines"> <table class="lines">
@@ -355,11 +134,12 @@
<div class="actions"> <div class="actions">
<select bind:value={statusChoice}> <select bind:value={statusChoice}>
<option value="">Change status…</option> <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> </select>
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button> <button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
<button class="secondary" onclick={sendToXero}>Send to Xero</button> <button class="secondary" onclick={sendToXero}>Send to Xero</button>
<button class="secondary" onclick={reopenOrder}>Reopen</button> <button class="secondary" onclick={reopenOrder}>Reopen</button>
<button class="secondary" onclick={closeOrder}>Close</button>
</div> </div>
{#if selectedOrder.status_history?.length} {#if selectedOrder.status_history?.length}
@@ -372,220 +152,6 @@
</ul> </ul>
</details> </details>
{/if} {/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> </div>
</section> {/if}
<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>
+7 -20
View File
@@ -1,30 +1,17 @@
import { redirect } from '@sveltejs/kit'; import { hasStoredClientSession } from '$lib/session';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access'; import type { Order } from '$lib/types';
const EMPTY = { orders: [], products: [], customers: [], xero: null } as const;
// Orders queue. Access is already enforced by the family +layout.ts guard.
export async function load({ fetch }) { export async function load({ fetch }) {
if (!hasStoredClientSession()) { if (!hasStoredClientSession()) {
return { ...EMPTY }; return { orders: [] as Order[] };
}
const session = getStoredClientSession();
if (!canManageOrdering(session)) {
// Customers (or anyone without manage rights) don't belong here.
throw redirect(307, getWorkspaceHomeHref(session));
} }
try { try {
const [orders, products, customers, xero] = await Promise.all([ const orders = await api.orderingAdmin.orders(undefined, fetch);
api.orderingAdmin.orders(undefined, fetch), return { orders };
api.orderingAdmin.products(fetch),
api.orderingAdmin.customers(fetch),
api.orderingAdmin.xeroStatus(fetch)
]);
return { orders, products, customers, xero };
} catch { } catch {
return { ...EMPTY }; return { orders: [] as Order[] };
} }
} }
@@ -0,0 +1,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: oklch(88% 0.014 145);
--costing-line-strong: oklch(78% 0.02 145); --costing-line-strong: oklch(78% 0.02 145);
--costing-warn: oklch(58% 0.14 72); --costing-warn: oklch(58% 0.14 72);
--costing-input-bg: oklch(99% 0.004 145);
--costing-warn-soft: oklch(95% 0.055 82);
--costing-warn-box: oklch(96% 0.045 84);
--costing-warn-border: oklch(84% 0.085 78);
--costing-warn-row: oklch(92% 0.08 81);
display: grid; display: grid;
gap: 1.05rem; gap: 1.05rem;
color: var(--costing-ink); color: var(--costing-ink);
} }
/* 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, .page-head,
.health-strip, .health-strip,
.workspace-grid, .workspace-grid,
@@ -809,7 +832,7 @@
padding: 0.38rem 0.62rem; padding: 0.38rem 0.62rem;
border-radius: 999px; border-radius: 999px;
color: var(--costing-warn); color: var(--costing-warn);
background: oklch(95% 0.055 82); background: var(--costing-warn-soft);
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 800; font-weight: 800;
} }
@@ -881,7 +904,7 @@
} }
.health-card.warning { .health-card.warning {
background: color-mix(in srgb, oklch(91% 0.08 80) 34%, var(--costing-panel)); background: color-mix(in srgb, var(--costing-warn-row) 34%, var(--costing-panel));
} }
.health-card strong { .health-card strong {
@@ -978,7 +1001,7 @@
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border: 1px solid var(--costing-line-strong); border: 1px solid var(--costing-line-strong);
border-radius: 0.66rem; border-radius: 0.66rem;
background: oklch(99% 0.004 145); background: var(--costing-input-bg);
color: var(--costing-ink); color: var(--costing-ink);
} }
@@ -995,7 +1018,7 @@
align-items: center; align-items: center;
border: 1px solid var(--costing-line-strong); border: 1px solid var(--costing-line-strong);
border-radius: 0.66rem; border-radius: 0.66rem;
background: oklch(99% 0.004 145); background: var(--costing-input-bg);
overflow: hidden; overflow: hidden;
} }
@@ -1085,7 +1108,7 @@
} }
tbody tr.warn td { tbody tr.warn td {
background: color-mix(in srgb, oklch(93% 0.08 83) 45%, var(--costing-panel)); background: color-mix(in srgb, var(--costing-warn-row) 45%, var(--costing-panel));
} }
tbody td { tbody td {
@@ -1166,7 +1189,7 @@
.status-pill.warning { .status-pill.warning {
color: var(--costing-warn); color: var(--costing-warn);
background: oklch(95% 0.055 82); background: var(--costing-warn-soft);
} }
.skeleton-row td { .skeleton-row td {
@@ -1257,10 +1280,10 @@
align-items: flex-start; align-items: flex-start;
padding: 0.78rem; padding: 0.78rem;
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
border: 1px solid oklch(84% 0.085 78); border: 1px solid var(--costing-warn-border);
border-radius: 0.82rem; border-radius: 0.82rem;
color: var(--costing-warn); color: var(--costing-warn);
background: oklch(96% 0.045 84); background: var(--costing-warn-box);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
} }
+13 -13
View File
@@ -617,7 +617,7 @@
} }
.eyebrow { .eyebrow {
color: #7f8e85; color: var(--color-text-muted);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -680,14 +680,14 @@
.feedback.success { .feedback.success {
color: var(--green-deep); color: var(--green-deep);
border-color: #d8ecdf; border-color: color-mix(in srgb, var(--color-success) 22%, var(--color-border));
background: #f6fcf8; background: color-mix(in srgb, var(--color-success-tint) 55%, var(--color-bg-surface));
} }
.feedback.error { .feedback.error {
color: #a03737; color: var(--color-error);
border-color: #f0d9d9; border-color: color-mix(in srgb, var(--color-error) 22%, var(--color-border));
background: #fff8f8; background: color-mix(in srgb, var(--color-error) 8%, var(--color-bg-surface));
} }
.metric-row, .metric-row,
@@ -839,7 +839,7 @@
label { label {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
color: #53645b; color: var(--color-text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
} }
@@ -860,7 +860,7 @@
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
border: none; border: none;
border-radius: 0.9rem; border-radius: 0.9rem;
color: #fff; color: var(--color-on-brand);
background: var(--color-brand); background: var(--color-brand);
box-shadow: none; box-shadow: none;
font-weight: 600; font-weight: 600;
@@ -955,13 +955,13 @@
} }
.material-icon.active { .material-icon.active {
color: #fff; color: var(--color-on-brand);
background: var(--color-brand); background: var(--color-brand);
} }
.material-icon.muted { .material-icon.muted {
color: #55685f; color: var(--color-text-secondary);
background: #e9efeb; background: var(--color-surface-hover);
} }
.status-pill { .status-pill {
@@ -981,8 +981,8 @@
} }
.status-pill.neutral { .status-pill.neutral {
color: #5a6c63; color: var(--color-text-secondary);
background: #edf2ef; background: var(--color-surface-hover);
} }
.material-grid { .material-grid {
+343 -5
View File
@@ -3,12 +3,17 @@
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte'; import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte'; import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import { clientSession } from '$lib/session'; import { clientSession } from '$lib/session';
import { canEditThroughput } from '$lib/workspace-access';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import { CircleUserRound, LockKeyhole } from 'lucide-svelte'; import type { ThroughputImportResult } from '$lib/types';
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte';
type Section = 'profile' | 'security'; type Section = 'profile' | 'security' | 'import';
let activeSection = $state<Section>('profile'); let activeSection = $state<Section>('profile');
// Only operators who can edit throughput see (and can use) the import tool.
const canImportThroughput = $derived(canEditThroughput($clientSession));
let name = $state($clientSession?.name ?? ''); let name = $state($clientSession?.name ?? '');
let email = $state($clientSession?.email ?? ''); let email = $state($clientSession?.email ?? '');
@@ -69,6 +74,90 @@
} }
} }
// ── Throughput import ─────────────────────────────────────────
let importFile = $state<File | null>(null);
let importing = $state(false);
let importResult = $state<ThroughputImportResult | null>(null);
let importError = $state('');
function onImportFileChange(event: Event) {
const input = event.currentTarget as HTMLInputElement;
importFile = input.files?.[0] ?? null;
importResult = null;
importError = '';
}
async function runImport() {
if (!importFile) {
importError = 'Choose a CSV or spreadsheet file first.';
return;
}
importing = true;
importError = '';
importResult = null;
const tid = toast.loading('Importing entries…');
try {
const result = await api.importThroughputEntries(importFile);
importResult = result;
toast.dismiss(tid);
if (result.entries_imported > 0) {
toast.success(
`Imported ${result.entries_imported} ${result.entries_imported === 1 ? 'entry' : 'entries'}`
);
} else {
toast.error('No entries were imported. Check the file and try again.');
}
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'Import failed';
importError = msg;
toast.error(msg);
} finally {
importing = false;
}
}
// Build a small sample CSV in the browser so operators have a working header
// row to copy from. The backend matches these headers case-insensitively.
function downloadTemplate() {
const headers = [
'Date',
'Product',
'Item ID',
'Quantity',
'Type',
'Bag Size',
'Packed By',
'For Order',
'Job Number',
'For Stock',
'Stock Quantity',
'Notes'
];
const example = [
'2026-06-12',
'Specialty Pigeon Breeder',
'',
'40',
'bags',
'20',
'Jane Doe',
'yes',
'JOB1234',
'no',
'',
'First run of the day'
];
const csv = `${headers.join(',')}\n${example.join(',')}\n`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'throughput-import-template.csv';
link.click();
URL.revokeObjectURL(url);
}
const initials = $derived( const initials = $derived(
($clientSession?.name ?? '') ($clientSession?.name ?? '')
.split(' ') .split(' ')
@@ -78,12 +167,13 @@
.toUpperCase() || '?' .toUpperCase() || '?'
); );
const navItems: { id: Section; label: string; icon: typeof CircleUserRound }[] = [ const navItems = $derived<{ id: Section; label: string; icon: typeof CircleUserRound }[]>([
{ id: 'profile', label: 'Profile', icon: CircleUserRound }, { id: 'profile', label: 'Profile', icon: CircleUserRound },
{ id: 'security', label: 'Security', icon: LockKeyhole }, { id: 'security', label: 'Security', icon: LockKeyhole },
]; ...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
]);
const railGroups = [{ items: navItems }]; const railGroups = $derived([{ items: navItems }]);
</script> </script>
<AppSecondaryRailLayout> <AppSecondaryRailLayout>
@@ -164,6 +254,92 @@
</div> </div>
</form> </form>
</div> </div>
{:else if activeSection === 'import' && canImportThroughput}
<div class="panel-section">
<header class="panel-header">
<h2>Import throughput entries</h2>
<p>Upload a CSV or Excel (.xlsx) file of packing runs. Each row is saved as a throughput entry.</p>
</header>
<div class="import-body">
<div class="import-help">
<h3>Required columns</h3>
<p>
Your file needs a header row with at least <strong>Date</strong>, <strong>Product</strong>
and <strong>Quantity</strong> columns. These optional columns are also recognised:
</p>
<ul>
<li><strong>Type</strong><code>bags</code> or <code>kg</code> (inferred from bag size if omitted)</li>
<li><strong>Bag Size</strong> — kg per bag (required when packing as bags)</li>
<li><strong>Item ID</strong> — matches an existing product; otherwise matched by name</li>
<li><strong>Packed By</strong>, <strong>Notes</strong></li>
<li><strong>For Order</strong>, <strong>Job Number</strong>, <strong>For Stock</strong>, <strong>Stock Quantity</strong></li>
</ul>
<p class="import-note">
Products that don't already exist are created automatically. Dates accept
<code>YYYY-MM-DD</code> or <code>DD/MM/YYYY</code>.
</p>
<button type="button" class="btn-link" onclick={downloadTemplate}>
<FileSpreadsheet size={15} strokeWidth={2.2} /> Download CSV template
</button>
</div>
<div class="import-control">
<label class="file-drop" class:has-file={!!importFile}>
<input
type="file"
accept=".csv,.xlsx,.xlsm,.xls,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onchange={onImportFileChange}
/>
<Upload size={22} strokeWidth={2} />
<span class="file-drop-label">
{importFile ? importFile.name : 'Choose a CSV or .xlsx file'}
</span>
{#if importFile}
<span class="file-drop-size">{(importFile.size / 1024).toFixed(1)} KB</span>
{/if}
</label>
{#if importError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {importError}</p>
{/if}
{#if importResult}
<div class="import-result" role="status">
<p class="import-result-head">
Imported <strong>{importResult.entries_imported}</strong>
{importResult.entries_imported === 1 ? 'entry' : 'entries'}.
</p>
<ul class="import-result-stats">
{#if importResult.products_created > 0}
<li>{importResult.products_created} new product{importResult.products_created === 1 ? '' : 's'} created</li>
{/if}
{#if importResult.entries_skipped > 0}
<li>{importResult.entries_skipped} row{importResult.entries_skipped === 1 ? '' : 's'} skipped</li>
{/if}
</ul>
{#if importResult.errors.length > 0}
<details class="import-errors">
<summary>{importResult.errors.length} issue{importResult.errors.length === 1 ? '' : 's'} to review</summary>
<ul>
{#each importResult.errors as err (err)}
<li>{err}</li>
{/each}
</ul>
</details>
{/if}
</div>
{/if}
<div class="form-footer">
<button class="btn-primary" type="button" disabled={importing || !importFile} onclick={runImport}>
{importing ? 'Importing…' : 'Import entries'}
</button>
</div>
</div>
</div>
</div>
{/if} {/if}
</div> </div>
</AppSecondaryRailLayout> </AppSecondaryRailLayout>
@@ -288,11 +464,173 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* ── Import ─────────────────────────────────────────────────── */
.import-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1.5rem;
padding: 1.5rem 1.75rem;
}
.import-help h3 {
margin: 0 0 0.5rem;
font-size: 0.92rem;
font-weight: 700;
color: var(--text);
}
.import-help p {
margin: 0 0 0.65rem;
font-size: 0.85rem;
line-height: 1.5;
color: var(--muted);
}
.import-help ul {
margin: 0 0 0.65rem;
padding-left: 1.1rem;
display: grid;
gap: 0.3rem;
font-size: 0.84rem;
color: var(--muted);
}
.import-help code {
padding: 0.05rem 0.32rem;
border-radius: 0.35rem;
background: var(--panel-soft);
border: 1px solid var(--line);
font-size: 0.8rem;
}
.import-note {
font-size: 0.82rem;
}
.btn-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0;
background: none;
border: none;
color: var(--color-brand);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.btn-link:hover {
text-decoration: underline;
}
.import-control {
display: grid;
gap: 1rem;
align-content: start;
}
.file-drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem 1.25rem;
border: 1.5px dashed var(--line);
border-radius: 0.75rem;
background: var(--panel-soft);
color: var(--muted);
cursor: pointer;
text-align: center;
transition: border-color 140ms ease, color 140ms ease;
}
.file-drop:hover {
border-color: var(--color-brand);
color: var(--text);
}
.file-drop.has-file {
border-style: solid;
border-color: var(--color-brand);
color: var(--text);
}
.file-drop input {
display: none;
}
.file-drop-label {
font-size: 0.88rem;
font-weight: 600;
word-break: break-all;
}
.file-drop-size {
font-size: 0.78rem;
color: var(--muted);
}
.form-error {
display: flex;
align-items: center;
gap: 0.4rem;
}
.import-result {
padding: 0.85rem 1rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 25%, transparent);
border-radius: 0.6rem;
background: color-mix(in srgb, var(--color-brand) 7%, transparent);
}
.import-result-head {
margin: 0;
font-size: 0.9rem;
color: var(--text);
}
.import-result-stats {
margin: 0.45rem 0 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
font-size: 0.83rem;
color: var(--muted);
}
.import-errors {
margin-top: 0.6rem;
font-size: 0.83rem;
color: var(--muted);
}
.import-errors summary {
cursor: pointer;
font-weight: 600;
color: var(--text);
}
.import-errors ul {
margin: 0.45rem 0 0;
padding-left: 1.1rem;
display: grid;
gap: 0.25rem;
max-height: 12rem;
overflow-y: auto;
}
/* ── Responsive ─────────────────────────────────────────────── */ /* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 720px) { @media (max-width: 720px) {
.field-row { .field-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.import-body {
grid-template-columns: 1fr;
}
} }
</style> </style>
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -5,7 +5,7 @@ import { canOpenThroughput, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) { export async function load({ fetch }) {
if (!hasStoredClientSession()) { if (!hasStoredClientSession()) {
return { entries: [], products: [] }; return { entries: [], statsEntries: [], products: [] };
} }
const session = getStoredClientSession(); const session = getStoredClientSession();
@@ -18,13 +18,22 @@ export async function load({ fetch }) {
recentFrom.setDate(recentFrom.getDate() - 30); recentFrom.setDate(recentFrom.getDate() - 30);
const dateFrom = recentFrom.toISOString().slice(0, 10); const dateFrom = recentFrom.toISOString().slice(0, 10);
// The overview cards are computed in the browser: the hero cards (today /
// this week / 4-week average) plus the customer-mix cards whose rolling range
// the operator can switch up to 12 weeks. Pull that widest window once so
// changing range is a pure re-filter with no extra request.
const statsFrom = new Date();
statsFrom.setDate(statsFrom.getDate() - 84);
const statsDateFrom = statsFrom.toISOString().slice(0, 10);
try { try {
const [entries, products] = await Promise.all([ const [entries, statsEntries, products] = await Promise.all([
api.throughputEntries({ date_from: dateFrom, limit: 200 }, fetch), api.throughputEntries({ date_from: dateFrom, limit: 200 }, fetch),
api.throughputEntries({ date_from: statsDateFrom, limit: 1000 }, fetch),
api.throughputProducts(fetch) api.throughputProducts(fetch)
]); ]);
return { entries, products }; return { entries, statsEntries, products };
} catch { } catch {
return { entries: [], products: [] }; return { entries: [], statsEntries: [], products: [] };
} }
} }