v0.1.19 - Throughput overview & responsive header
- Throughput: new "Throughput Overview" header with Gauge icon and brand-green badge icons on each card (Today, This week, 4-week average, Horse Mix, Grain Mix) - Throughput: inline rolling-range selector (7d / 4w / 6w / 12w, default 4 weeks) driving the customer-mix cards; stats window widened to 12 weeks so switching range is a pure client-side re-filter - Throughput: cards collapse to a single even 5-across row on laptop and up, with container-query value text that scales to each card's width - Throughput: date logic pinned to Australian Eastern time (fixes the day-early date); This week subtitle shows the Mon-Sun date range - Throughput: subtler tinted add-form; removed the inline-entry kicker and the "Open full form" link - Topbar: fix cramped laptop header - action toggles no longer wrap above the user button; search drops to its own row earlier Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+320
-11
@@ -1,14 +1,21 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from 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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ class MixCalculatorSessionLine(Base):
|
|||||||
required_kg: Mapped[float] = mapped_column(Float)
|
required_kg: Mapped[float] = mapped_column(Float)
|
||||||
mix_percentage: Mapped[float] = mapped_column(Float)
|
mix_percentage: Mapped[float] = mapped_column(Float)
|
||||||
unit: Mapped[str] = mapped_column(String(64))
|
unit: Mapped[str] = mapped_column(String(64))
|
||||||
|
# Snapshot of the ingredient's rounding setting at save time.
|
||||||
|
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
|
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
|
||||||
|
|||||||
@@ -376,6 +376,34 @@ class NotificationSetting(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class XeroContactLink(Base):
|
||||||
|
"""Persistent link between a customer (:class:`ClientAccount`) and a Xero
|
||||||
|
contact.
|
||||||
|
|
||||||
|
Maintained from the Integrations console. Once a customer is linked, order
|
||||||
|
invoices reference the real Xero ``ContactID`` instead of falling back to the
|
||||||
|
client code — which is what lets Xero attach the invoice to the right
|
||||||
|
contact rather than creating a duplicate. One link per customer per tenant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "xero_contact_links"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "client_account_id", name="uq_xero_contact_link_customer"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
|
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||||
|
xero_contact_id: Mapped[str] = mapped_column(String(128))
|
||||||
|
xero_contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
xero_contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
# When the contact details were last reconciled with Xero (a future live
|
||||||
|
# sync can refresh name/email and stamp this).
|
||||||
|
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class XeroSyncLog(Base):
|
class XeroSyncLog(Base):
|
||||||
__tablename__ = "xero_sync_log"
|
__tablename__ = "xero_sync_log"
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text
|
from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
@@ -18,6 +18,9 @@ class RawMaterial(Base):
|
|||||||
unit_of_measure: Mapped[str] = mapped_column(String(64))
|
unit_of_measure: Mapped[str] = mapped_column(String(64))
|
||||||
kg_per_unit: Mapped[float] = mapped_column(Float)
|
kg_per_unit: Mapped[float] = mapped_column(Float)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||||
|
# Decimal places this ingredient's required-kg is rounded to in the mix
|
||||||
|
# calculator output. Set per-ingredient from the Ingredients Editor.
|
||||||
|
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class MixCalculatorSessionLineRead(BaseModel):
|
|||||||
required_kg: float
|
required_kg: float
|
||||||
mix_percentage: float
|
mix_percentage: float
|
||||||
unit: str
|
unit: str
|
||||||
|
rounding_decimals: int = 2
|
||||||
sort_order: int
|
sort_order: int
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,18 @@ class NotificationSettingsUpdate(BaseModel):
|
|||||||
from_email: str | None = None
|
from_email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin: Xero integration -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class XeroContactLinkUpsert(BaseModel):
|
||||||
|
"""Link a customer to a Xero contact. ``xero_contact_id`` is the Xero
|
||||||
|
``ContactID`` (or, in stub mode, the deterministic stub id)."""
|
||||||
|
|
||||||
|
xero_contact_id: str = Field(min_length=1, max_length=128)
|
||||||
|
xero_contact_name: str | None = Field(default=None, max_length=255)
|
||||||
|
xero_contact_email: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
# --- Admin: customers & users ------------------------------------------------
|
# --- Admin: customers & users ------------------------------------------------
|
||||||
|
|
||||||
_CUSTOMER_STATUSES = {"active", "disabled"}
|
_CUSTOMER_STATUSES = {"active", "disabled"}
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ class ThroughputEntryUpdate(BaseModel):
|
|||||||
notes: str | None = Field(default=None, max_length=2000)
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class ThroughputImportResult(BaseModel):
|
||||||
|
entries_imported: int
|
||||||
|
entries_skipped: int
|
||||||
|
products_created: int
|
||||||
|
errors: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ThroughputEntryRead(BaseModel):
|
class ThroughputEntryRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
|
|||||||
@@ -319,7 +319,10 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
|
|||||||
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
|
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
|
||||||
)
|
)
|
||||||
pdf.setFont("Helvetica", table_font_size)
|
pdf.setFont("Helvetica", table_font_size)
|
||||||
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}kg")
|
# Each ingredient carries its own rounding (set in the Ingredients Editor)
|
||||||
|
# so the printed sheet matches the on-screen calculated output.
|
||||||
|
line_decimals = getattr(line, "rounding_decimals", 2)
|
||||||
|
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg, line_decimals)}kg")
|
||||||
|
|
||||||
strip_y = table_bottom - 6
|
strip_y = table_bottom - 6
|
||||||
if note_lines:
|
if note_lines:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
|||||||
"raw_material_name": ingredient.raw_material.name,
|
"raw_material_name": ingredient.raw_material.name,
|
||||||
"quantity_kg": ingredient.quantity_kg,
|
"quantity_kg": ingredient.quantity_kg,
|
||||||
"unit": ingredient.raw_material.unit_of_measure,
|
"unit": ingredient.raw_material.unit_of_measure,
|
||||||
|
"rounding_decimals": ingredient.raw_material.rounding_decimals,
|
||||||
"sort_order": ingredient.sort_order,
|
"sort_order": ingredient.sort_order,
|
||||||
}
|
}
|
||||||
for ingredient in product.ingredients
|
for ingredient in product.ingredients
|
||||||
@@ -55,6 +56,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
|||||||
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
|
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
|
||||||
"quantity_kg": ingredient.quantity_kg,
|
"quantity_kg": ingredient.quantity_kg,
|
||||||
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
||||||
|
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
|
||||||
"sort_order": index,
|
"sort_order": index,
|
||||||
}
|
}
|
||||||
for index, ingredient in enumerate(product.mix.ingredients, start=1)
|
for index, ingredient in enumerate(product.mix.ingredients, start=1)
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.models.client_access import ClientAccount
|
from app.models.client_access import ClientAccount
|
||||||
from app.models.ordering import Order
|
from app.models.ordering import Order, XeroContactLink
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -57,11 +57,75 @@ class XeroSubmissionResult:
|
|||||||
line_items: list[dict] = field(default_factory=list)
|
line_items: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def map_customer_to_contact(customer: ClientAccount) -> dict:
|
@dataclass
|
||||||
"""Map a customer account onto a Xero contact payload."""
|
class XeroContact:
|
||||||
|
"""A Xero contact available to link a customer against."""
|
||||||
|
|
||||||
|
contact_id: str
|
||||||
|
name: str
|
||||||
|
email: str | None = None
|
||||||
|
status: str = "ACTIVE"
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"contact_id": self.contact_id,
|
||||||
|
"name": self.name,
|
||||||
|
"email": self.email,
|
||||||
|
"status": self.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Deterministic sample contacts used while running in stub mode (no Xero
|
||||||
|
# credentials). They stand in for "what's in Xero" so the customer→contact
|
||||||
|
# mapping UI is usable before the live API is wired. Ids mimic Xero GUIDs.
|
||||||
|
_STUB_CONTACTS: tuple[XeroContact, ...] = (
|
||||||
|
XeroContact("STUB-CON-0001", "Hunter Premium Produce", "accounts@hunterpremium.example", "ACTIVE"),
|
||||||
|
XeroContact("STUB-CON-0002", "Mayreef Pty Ltd", "ap@mayreef.example", "ACTIVE"),
|
||||||
|
XeroContact("STUB-CON-0003", "Ian McKay Stock Feeds", "ian@mckayfeeds.example", "ACTIVE"),
|
||||||
|
XeroContact("STUB-CON-0004", "Peckish Bird Foods", "orders@peckish.example", "ACTIVE"),
|
||||||
|
XeroContact("STUB-CON-0005", "Hay & Straw Co", "info@hayandstraw.example", "ACTIVE"),
|
||||||
|
XeroContact("STUB-CON-0006", "PHF Horse Mixes", "accounts@phfhorse.example", "ACTIVE"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_contacts_from_api(config: XeroConfig) -> list[XeroContact]:
|
||||||
|
"""Live contact fetch. Stubbed until credentials/endpoints are wired.
|
||||||
|
|
||||||
|
TODO (go-live): GET ``{config.base_url}/Contacts`` with the
|
||||||
|
``Xero-tenant-id`` header, page through ``Contacts[]`` and map each onto a
|
||||||
|
:class:`XeroContact` (``ContactID``/``Name``/``EmailAddress``/``ContactStatus``).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("Live Xero contact fetch is not implemented yet.")
|
||||||
|
|
||||||
|
|
||||||
|
def list_xero_contacts(config: XeroConfig | None = None) -> tuple[list[XeroContact], bool]:
|
||||||
|
"""Return the Xero contacts available for linking and whether they're stubbed.
|
||||||
|
|
||||||
|
Never raises — on a live-mode error it returns an empty list so the mapping
|
||||||
|
console still renders.
|
||||||
|
"""
|
||||||
|
config = config or XeroConfig.from_env()
|
||||||
|
if not config.configured:
|
||||||
|
return list(_STUB_CONTACTS), True
|
||||||
|
try:
|
||||||
|
return _fetch_contacts_from_api(config), False
|
||||||
|
except Exception: # pragma: no cover - defensive: never break the request path
|
||||||
|
return [], False
|
||||||
|
|
||||||
|
|
||||||
|
def map_customer_to_contact(customer: ClientAccount, link: XeroContactLink | None = None) -> dict:
|
||||||
|
"""Map a customer account onto a Xero contact payload.
|
||||||
|
|
||||||
|
When the customer has been linked to a Xero contact we send the real
|
||||||
|
``ContactID`` so Xero attaches the invoice to the existing contact. Without a
|
||||||
|
link we fall back to keying on the client code (Xero will match-or-create).
|
||||||
|
"""
|
||||||
|
if link is not None and link.xero_contact_id:
|
||||||
|
return {
|
||||||
|
"ContactID": link.xero_contact_id,
|
||||||
|
"Name": link.xero_contact_name or customer.name,
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
# TODO: persist and reuse a real Xero ContactID once the contact has
|
|
||||||
# been created/matched in Xero. For now we key on the client code.
|
|
||||||
"ContactNumber": customer.client_code,
|
"ContactNumber": customer.client_code,
|
||||||
"Name": customer.name,
|
"Name": customer.name,
|
||||||
}
|
}
|
||||||
@@ -76,7 +140,9 @@ def map_product_to_item_code(product_sku: str) -> str:
|
|||||||
return product_sku
|
return product_sku
|
||||||
|
|
||||||
|
|
||||||
def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
|
def build_invoice_payload(
|
||||||
|
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
|
||||||
|
) -> dict:
|
||||||
"""Build the Xero draft-invoice payload for a confirmed order."""
|
"""Build the Xero draft-invoice payload for a confirmed order."""
|
||||||
line_items = []
|
line_items = []
|
||||||
for line in order.lines:
|
for line in order.lines:
|
||||||
@@ -98,7 +164,7 @@ def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
|
|||||||
return {
|
return {
|
||||||
"Type": "ACCREC",
|
"Type": "ACCREC",
|
||||||
"Status": "DRAFT",
|
"Status": "DRAFT",
|
||||||
"Contact": map_customer_to_contact(customer),
|
"Contact": map_customer_to_contact(customer, link),
|
||||||
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
|
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
|
||||||
"LineAmountTypes": "Exclusive",
|
"LineAmountTypes": "Exclusive",
|
||||||
"LineItems": line_items,
|
"LineItems": line_items,
|
||||||
@@ -127,14 +193,17 @@ def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResu
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def submit_order_to_xero(order: Order, customer: ClientAccount) -> XeroSubmissionResult:
|
def submit_order_to_xero(
|
||||||
|
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
|
||||||
|
) -> XeroSubmissionResult:
|
||||||
"""Submit a confirmed order to Xero, or stub it when unconfigured.
|
"""Submit a confirmed order to Xero, or stub it when unconfigured.
|
||||||
|
|
||||||
Never raises — failures are returned as ``status="failed"`` results so the
|
Pass ``link`` to invoice against the customer's mapped Xero contact. Never
|
||||||
order lifecycle can record the attempt and continue.
|
raises — failures are returned as ``status="failed"`` results so the order
|
||||||
|
lifecycle can record the attempt and continue.
|
||||||
"""
|
"""
|
||||||
config = XeroConfig.from_env()
|
config = XeroConfig.from_env()
|
||||||
payload = build_invoice_payload(order, customer)
|
payload = build_invoice_payload(order, customer, link)
|
||||||
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
|
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
|
||||||
|
|
||||||
if not config.configured:
|
if not config.configured:
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "data-entry-app-backend"
|
name = "data-entry-app-backend"
|
||||||
version = "0.1.14"
|
version = "0.1.19"
|
||||||
description = "Costing platform MVP backend"
|
description = "Costing platform MVP backend"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115,<1.0",
|
"fastapi>=0.115,<1.0",
|
||||||
|
"python-multipart>=0.0.9,<1.0",
|
||||||
"openpyxl>=3.1,<4.0",
|
"openpyxl>=3.1,<4.0",
|
||||||
"rich>=13.9,<15.0",
|
"rich>=13.9,<15.0",
|
||||||
"uvicorn[standard]>=0.30,<1.0",
|
"uvicorn[standard]>=0.30,<1.0",
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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": {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Modern hover/focus tooltip action.
|
||||||
|
*
|
||||||
|
* Renders a styled bubble appended to <body> with fixed positioning, so it is
|
||||||
|
* never clipped by an overflow:hidden ancestor (e.g. the topbar). Themes via the
|
||||||
|
* shared design tokens — see `.app-tooltip` in styles/theme.css.
|
||||||
|
*
|
||||||
|
* Usage: <button use:tooltip={'Switch to dark mode'}>…</button>
|
||||||
|
* <button use:tooltip={{ label: 'What’s new', placement: 'bottom' }}>…</button>
|
||||||
|
*/
|
||||||
|
type Placement = 'top' | 'bottom';
|
||||||
|
|
||||||
|
type TooltipOptions = string | { label: string; placement?: Placement; delay?: number };
|
||||||
|
|
||||||
|
const GAP = 8;
|
||||||
|
const DEFAULT_DELAY = 300;
|
||||||
|
|
||||||
|
function normalize(options: TooltipOptions): { label: string; placement: Placement; delay: number } {
|
||||||
|
if (typeof options === 'string') {
|
||||||
|
return { label: options, placement: 'bottom', delay: DEFAULT_DELAY };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: options.label,
|
||||||
|
placement: options.placement ?? 'bottom',
|
||||||
|
delay: options.delay ?? DEFAULT_DELAY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tooltip(node: HTMLElement, options: TooltipOptions) {
|
||||||
|
let current = normalize(options);
|
||||||
|
let bubble: HTMLDivElement | null = null;
|
||||||
|
let showTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function position() {
|
||||||
|
if (!bubble) return;
|
||||||
|
const anchor = node.getBoundingClientRect();
|
||||||
|
const tip = bubble.getBoundingClientRect();
|
||||||
|
|
||||||
|
let left = anchor.left + anchor.width / 2 - tip.width / 2;
|
||||||
|
left = Math.max(GAP, Math.min(left, window.innerWidth - tip.width - GAP));
|
||||||
|
|
||||||
|
const top =
|
||||||
|
current.placement === 'top'
|
||||||
|
? anchor.top - tip.height - GAP
|
||||||
|
: anchor.bottom + GAP;
|
||||||
|
|
||||||
|
bubble.style.left = `${Math.round(left)}px`;
|
||||||
|
bubble.style.top = `${Math.round(top)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
if (bubble || !current.label) return;
|
||||||
|
bubble = document.createElement('div');
|
||||||
|
bubble.className = 'app-tooltip';
|
||||||
|
bubble.setAttribute('role', 'tooltip');
|
||||||
|
bubble.dataset.placement = current.placement;
|
||||||
|
bubble.textContent = current.label;
|
||||||
|
document.body.appendChild(bubble);
|
||||||
|
position();
|
||||||
|
// Trigger the fade/translate transition on the next frame.
|
||||||
|
requestAnimationFrame(() => bubble?.classList.add('is-visible'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleShow() {
|
||||||
|
clearTimeout(showTimer ?? undefined);
|
||||||
|
showTimer = setTimeout(show, current.delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
clearTimeout(showTimer ?? undefined);
|
||||||
|
showTimer = null;
|
||||||
|
bubble?.remove();
|
||||||
|
bubble = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('mouseenter', scheduleShow);
|
||||||
|
node.addEventListener('mouseleave', hide);
|
||||||
|
node.addEventListener('focus', show);
|
||||||
|
node.addEventListener('blur', hide);
|
||||||
|
node.addEventListener('click', hide);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next: TooltipOptions) {
|
||||||
|
current = normalize(next);
|
||||||
|
if (bubble) {
|
||||||
|
bubble.textContent = current.label;
|
||||||
|
bubble.dataset.placement = current.placement;
|
||||||
|
position();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
hide();
|
||||||
|
node.removeEventListener('mouseenter', scheduleShow);
|
||||||
|
node.removeEventListener('mouseleave', hide);
|
||||||
|
node.removeEventListener('focus', show);
|
||||||
|
node.removeEventListener('blur', hide);
|
||||||
|
node.removeEventListener('click', hide);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+109
-2
@@ -9,6 +9,11 @@ import type {
|
|||||||
ClientUserUpdateInput,
|
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')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.'
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Shared formatting helpers for the ordering management console. Kept tiny and
|
||||||
|
// dependency-free so each split route page (orders, products, pricing, …) can
|
||||||
|
// import them instead of re-declaring the same money/label functions.
|
||||||
|
|
||||||
|
const AUD = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
|
||||||
|
|
||||||
|
/** Format a value as AUD, or an em dash when null/undefined. */
|
||||||
|
export function money(value: number | null | undefined): string {
|
||||||
|
if (value == null) return '—';
|
||||||
|
return AUD.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn a snake_case status/category key into Title Case for display. */
|
||||||
|
export function label(value: string): string {
|
||||||
|
return value.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Order workflow statuses, in the order they typically progress. */
|
||||||
|
export const ORDER_STATUSES = [
|
||||||
|
'submitted',
|
||||||
|
'under_review',
|
||||||
|
'confirmed',
|
||||||
|
'sent_to_xero',
|
||||||
|
'in_production',
|
||||||
|
'ready_for_pickup',
|
||||||
|
'dispatched',
|
||||||
|
'completed',
|
||||||
|
'cancelled'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an order / customer status to a pill tone class so states are scannable
|
||||||
|
* at a glance: warn = needs attention, info = in flight, pos = done/active,
|
||||||
|
* danger = cancelled. Unknown values fall back to the neutral base pill.
|
||||||
|
*/
|
||||||
|
const STATUS_TONE: Record<string, string> = {
|
||||||
|
submitted: 'warn',
|
||||||
|
under_review: 'warn',
|
||||||
|
confirmed: 'pos',
|
||||||
|
completed: 'pos',
|
||||||
|
active: 'pos',
|
||||||
|
cancelled: 'danger',
|
||||||
|
disabled: 'danger',
|
||||||
|
suspended: 'danger',
|
||||||
|
sent_to_xero: 'info',
|
||||||
|
in_production: 'info',
|
||||||
|
ready_for_pickup: 'info',
|
||||||
|
dispatched: 'info'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function statusTone(value: string): string {
|
||||||
|
return STATUS_TONE[value] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catalogue product categories. */
|
||||||
|
export const PRODUCT_CATEGORIES = [
|
||||||
|
'grains',
|
||||||
|
'premixed',
|
||||||
|
'bags',
|
||||||
|
'bulk_loads',
|
||||||
|
'custom_blends',
|
||||||
|
'services'
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/*
|
||||||
|
* Shared styles for the Order Management family. Imported once by
|
||||||
|
* routes/ordering/manage/+layout.svelte (so the rules are global) but every
|
||||||
|
* selector is scoped under `.manage-shell` — the layout wrapper that contains
|
||||||
|
* all child route markup — so nothing leaks into the rest of the app.
|
||||||
|
*
|
||||||
|
* Everything resolves from the design tokens in $lib/styles/theme.css so the
|
||||||
|
* console re-themes (light/dark) automatically and stays visually consistent
|
||||||
|
* with the rest of the app. No hard-coded colours.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.manage-shell h2 { margin: 0 0 0.75rem; font-size: 1.02rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||||
|
.manage-shell h3 { margin: 0 0 0.45rem; font-size: 0.86rem; font-weight: 700; color: var(--color-text-secondary); }
|
||||||
|
.manage-shell .mt { margin-top: 1.15rem; }
|
||||||
|
.manage-shell .muted { color: var(--color-text-muted); font-size: 0.84rem; margin: 0 0 0.65rem; }
|
||||||
|
.manage-shell .muted a { color: var(--color-brand); font-weight: 600; }
|
||||||
|
.manage-shell .muted a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.manage-shell .surface-card {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
padding: var(--space-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-shell .split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tables: compact console rows, divider-separated ────────── */
|
||||||
|
.manage-shell table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
.manage-shell th,
|
||||||
|
.manage-shell td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--color-divider); }
|
||||||
|
.manage-shell tr:last-child td { border-bottom: none; }
|
||||||
|
.manage-shell th {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-bottom-color: var(--color-border);
|
||||||
|
}
|
||||||
|
.manage-shell tbody tr { transition: background-color 140ms cubic-bezier(0.22, 1, 0.36, 1); }
|
||||||
|
.manage-shell tbody tr:hover { background: var(--color-surface-hover); }
|
||||||
|
.manage-shell tbody tr.selected { background: var(--color-surface-selected); }
|
||||||
|
.manage-shell table.clickable tbody tr { cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Status pills: neutral by default, tinted by tone ──────── */
|
||||||
|
.manage-shell .pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0.18rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: color-mix(in srgb, var(--panel-soft) 70%, var(--color-bg-surface));
|
||||||
|
}
|
||||||
|
.manage-shell .pill.pos { color: var(--color-success-text); background: var(--color-success-tint); }
|
||||||
|
.manage-shell .pill.warn { color: var(--color-warning-text); background: var(--color-warning-tint); }
|
||||||
|
.manage-shell .pill.info { color: var(--color-info); background: var(--color-info-tint); }
|
||||||
|
.manage-shell .pill.danger { color: var(--color-error); background: color-mix(in srgb, var(--color-error) 14%, var(--color-bg-surface)); }
|
||||||
|
|
||||||
|
.manage-shell .tag {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.08rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: var(--color-warning-tint);
|
||||||
|
color: var(--color-warning-text);
|
||||||
|
}
|
||||||
|
.manage-shell .empty { color: var(--color-text-muted); font-size: 0.85rem; margin: 0.2rem 0; }
|
||||||
|
|
||||||
|
/* ── Forms ──────────────────────────────────────────────────── */
|
||||||
|
.manage-shell .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem 0.65rem; align-items: end; }
|
||||||
|
.manage-shell .form-grid .full { grid-column: 1 / -1; }
|
||||||
|
.manage-shell .form-grid label,
|
||||||
|
.manage-shell .form-row label { display: grid; gap: 0.28rem; font-size: 0.76rem; font-weight: 600; color: var(--color-text-secondary); }
|
||||||
|
.manage-shell .form-row { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; margin-bottom: 0.6rem; }
|
||||||
|
|
||||||
|
.manage-shell .form-row input,
|
||||||
|
.manage-shell .form-row select,
|
||||||
|
.manage-shell .form-grid input,
|
||||||
|
.manage-shell .form-grid select,
|
||||||
|
.manage-shell .actions select,
|
||||||
|
.manage-shell .inline,
|
||||||
|
.manage-shell .ovr {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
transition: border-color 140ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
box-shadow 140ms cubic-bezier(0.22, 1, 0.36, 1), background-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
.manage-shell input:focus,
|
||||||
|
.manage-shell select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
|
||||||
|
}
|
||||||
|
.manage-shell input:disabled,
|
||||||
|
.manage-shell select:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.manage-shell .check { display: flex; flex-direction: row; align-items: center; gap: 0.45rem; font-weight: 600; color: var(--color-text-secondary); }
|
||||||
|
.manage-shell .check input { accent-color: var(--color-brand); width: 1rem; height: 1rem; }
|
||||||
|
.manage-shell .inline-label { flex-direction: row; align-items: center; gap: 0.5rem; }
|
||||||
|
.manage-shell .inline { width: 6.5rem; padding: 0.4rem 0.5rem; }
|
||||||
|
.manage-shell .ovr { width: 5.5rem; padding: 0.4rem 0.5rem; }
|
||||||
|
|
||||||
|
/* ── Buttons ────────────────────────────────────────────────── */
|
||||||
|
.manage-shell .primary,
|
||||||
|
.manage-shell .secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.3rem;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
padding: 0.5rem 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background-color 150ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
border-color 150ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
.manage-shell .primary { background: var(--color-brand); border-color: var(--color-brand); color: var(--color-on-brand); }
|
||||||
|
.manage-shell .primary:hover:not(:disabled) { background: var(--color-brand-hover); border-color: var(--color-brand-hover); }
|
||||||
|
.manage-shell .secondary { background: var(--color-bg-surface); border-color: var(--color-border); color: var(--color-text-primary); }
|
||||||
|
.manage-shell .secondary:hover:not(:disabled) { background: var(--color-surface-hover); }
|
||||||
|
.manage-shell .primary:disabled,
|
||||||
|
.manage-shell .secondary:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.manage-shell .link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-brand);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.manage-shell .link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Order / customer detail ────────────────────────────────── */
|
||||||
|
.manage-shell .lines td { font-size: 0.82rem; }
|
||||||
|
.manage-shell .detail-total {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.65rem 0.05rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin: 0.5rem 0 0.85rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.manage-shell .detail-total strong { font-size: 1.05rem; color: var(--color-text-primary); }
|
||||||
|
.manage-shell .actions { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; }
|
||||||
|
.manage-shell .history { margin-top: 0.95rem; font-size: 0.8rem; color: var(--color-text-secondary); }
|
||||||
|
.manage-shell .history summary { cursor: pointer; font-weight: 600; color: var(--color-text-secondary); }
|
||||||
|
.manage-shell .history summary:hover { color: var(--color-text-primary); }
|
||||||
|
.manage-shell .history ul { margin: 0.5rem 0 0; padding-left: 1.1rem; display: grid; gap: 0.3rem; }
|
||||||
|
|
||||||
|
.manage-shell .mini { list-style: none; margin: 0.3rem 0 0.7rem; padding: 0; display: grid; gap: 0.4rem; font-size: 0.82rem; }
|
||||||
|
.manage-shell .mini li {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
.manage-shell .visibility li { justify-content: flex-start; }
|
||||||
|
|
||||||
|
/* ── Card header: title on the left, primary action on the right ── */
|
||||||
|
.manage-shell .card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
.manage-shell .card-head h2 { margin: 0; }
|
||||||
|
|
||||||
|
/* ── Modal ──────────────────────────────────────────────────── */
|
||||||
|
.manage-shell .modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: grid;
|
||||||
|
place-items: start center;
|
||||||
|
padding: 10vh 1rem 1rem;
|
||||||
|
background: color-mix(in srgb, var(--color-text-primary) 32%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.manage-shell .modal {
|
||||||
|
width: min(30rem, 100%);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
padding: var(--space-card);
|
||||||
|
}
|
||||||
|
.manage-shell .modal.wide { width: min(40rem, 100%); }
|
||||||
|
.manage-shell .modal h2 { margin: 0 0 1rem; }
|
||||||
|
.manage-shell .modal .actions { justify-content: flex-end; margin-top: 1.15rem; }
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.manage-shell .split,
|
||||||
|
.manage-shell .form-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -46,18 +46,20 @@
|
|||||||
--color-text-secondary: oklch(0.45 0.008 240);
|
--color-text-secondary: oklch(0.45 0.008 240);
|
||||||
--color-text-muted: oklch(0.6 0.01 240);
|
--color-text-muted: oklch(0.6 0.01 240);
|
||||||
|
|
||||||
/* ── Sidebar: light monochrome rail with the current item shown as the
|
/* ── Sidebar: deep-green rail matching the customer ordering portal
|
||||||
selected pill. Shared across themes so navigation stays consistent. ── */
|
(CustomerPortalShell). The current item shows as a white pill with
|
||||||
--sidebar-bg: oklch(0.985 0.001 240);
|
green text. Held constant across light/dark themes so internal staff
|
||||||
--sidebar-hover: oklch(0.952 0.003 240);
|
and customers see the same navigation styling. ── */
|
||||||
--sidebar-active-bg: #3290d9;
|
--sidebar-bg: #1f3a2c;
|
||||||
--sidebar-active-text: var(--color-on-brand);
|
--sidebar-hover: rgba(255, 255, 255, 0.08);
|
||||||
--sidebar-border: oklch(0.9 0.004 240);
|
--sidebar-active-bg: #ffffff;
|
||||||
--sidebar-text: oklch(0.34 0.006 240);
|
--sidebar-active-text: #1f3a2c;
|
||||||
--sidebar-text-strong: oklch(0.16 0.004 240);
|
--sidebar-border: rgba(255, 255, 255, 0.12);
|
||||||
--sidebar-text-muted: oklch(0.56 0.008 240);
|
--sidebar-text: rgba(231, 239, 233, 0.85);
|
||||||
--sidebar-icon: oklch(0.42 0.006 240);
|
--sidebar-text-strong: #ffffff;
|
||||||
--sidebar-logo-bg: oklch(0.98 0.003 240);
|
--sidebar-text-muted: rgba(231, 239, 233, 0.6);
|
||||||
|
--sidebar-icon: rgba(231, 239, 233, 0.8);
|
||||||
|
--sidebar-logo-bg: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
/* ── Semantic ───────────────────────────────────────────── */
|
/* ── Semantic ───────────────────────────────────────────── */
|
||||||
--color-success: oklch(0.66 0.16 162); /* emerald, cohesive with accent */
|
--color-success: oklch(0.66 0.16 162); /* emerald, cohesive with accent */
|
||||||
@@ -128,19 +130,20 @@
|
|||||||
--color-border: oklch(0.32 0.006 240);
|
--color-border: oklch(0.32 0.006 240);
|
||||||
--color-divider: oklch(0.28 0.005 240);
|
--color-divider: oklch(0.28 0.005 240);
|
||||||
|
|
||||||
/* ── Sidebar: dark rail tuned to the content theme so it stops
|
/* ── Sidebar: in dark mode the rail drops the deep-green and joins the
|
||||||
rendering as a bright light strip in dark mode. Active item keeps
|
neutral dark surfaces, sitting a touch below the app canvas so it
|
||||||
the blue pill from light mode. ── */
|
reads as a distinct rail. The active item becomes a green-tinted pill
|
||||||
|
with bright brand text rather than the light-mode white chip. ── */
|
||||||
--sidebar-bg: oklch(0.2 0.005 240);
|
--sidebar-bg: oklch(0.2 0.005 240);
|
||||||
--sidebar-hover: oklch(0.27 0.006 240);
|
--sidebar-hover: oklch(0.27 0.006 240);
|
||||||
--sidebar-active-bg: #3290d9;
|
--sidebar-active-bg: color-mix(in srgb, var(--color-brand) 22%, var(--color-bg-surface));
|
||||||
--sidebar-active-text: var(--color-on-brand);
|
--sidebar-active-text: oklch(0.9 0.07 162);
|
||||||
--sidebar-border: oklch(0.3 0.006 240);
|
--sidebar-border: oklch(0.3 0.006 240);
|
||||||
--sidebar-text: oklch(0.78 0.006 240);
|
--sidebar-text: oklch(0.78 0.006 240);
|
||||||
--sidebar-text-strong: oklch(0.96 0.003 240);
|
--sidebar-text-strong: oklch(0.96 0.003 240);
|
||||||
--sidebar-text-muted: oklch(0.6 0.008 240);
|
--sidebar-text-muted: oklch(0.6 0.008 240);
|
||||||
--sidebar-icon: oklch(0.68 0.008 240);
|
--sidebar-icon: oklch(0.72 0.006 240);
|
||||||
--sidebar-logo-bg: oklch(0.26 0.006 240);
|
--sidebar-logo-bg: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
/* ── Text (neutral) ─────────────────────────────────────── */
|
/* ── Text (neutral) ─────────────────────────────────────── */
|
||||||
--color-text-primary: oklch(0.96 0.003 240);
|
--color-text-primary: oklch(0.96 0.003 240);
|
||||||
@@ -560,3 +563,49 @@ a {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Tooltip (rendered to <body> by the use:tooltip action)
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Auto-inverts via the text/surface tokens: a near-black bubble in light mode,
|
||||||
|
a near-white bubble in dark mode — readable contrast in both.
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.app-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 0.36rem 0.55rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-text-primary);
|
||||||
|
color: var(--color-bg-surface);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
text-align: center;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(16rem, calc(100vw - 1rem));
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 6px 20px -6px rgba(0, 0, 0, 0.35), 0 2px 6px -2px rgba(0, 0, 0, 0.25);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
transition: opacity 130ms ease, transform 130ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tooltip[data-placement='top'] {
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-tooltip.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-tooltip {
|
||||||
|
transition: opacity 130ms ease;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ChevronDown, ChevronsUpDown, ChevronUp } from 'lucide-svelte';
|
||||||
|
import type { TableController } from './table.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
column,
|
||||||
|
controller
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
controller: TableController<any>;
|
||||||
|
column: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const active = $derived(controller.sortKey === column);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sort-header"
|
||||||
|
class:active
|
||||||
|
aria-label={`Sort by ${label}${active ? (controller.sortDir === 'asc' ? ', ascending' : ', descending') : ''}`}
|
||||||
|
onclick={() => controller.toggleSort(column)}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{#if active}
|
||||||
|
{#if controller.sortDir === 'asc'}
|
||||||
|
<ChevronUp size={13} strokeWidth={2.6} />
|
||||||
|
{:else}
|
||||||
|
<ChevronDown size={13} strokeWidth={2.6} />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<ChevronsUpDown size={13} strokeWidth={2} />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sort-header {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
/* Inherit the .log-head typography (size/weight/transform/letter-spacing). */
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-header :global(svg) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-header:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-header:hover :global(svg) {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-header.active {
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-header.active :global(svg) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-header:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Shared client-side table controller: sorting + pagination over an already
|
||||||
|
// filtered row set. Both the Mix Editor and Ingredients Editor feed their
|
||||||
|
// filtered `visibleRows` in and render `rows` out, so the two surfaces stay
|
||||||
|
// behaviourally identical. Keeping the page math here (rather than ad-hoc
|
||||||
|
// `$effect`s per page) means navigation can't get clamped back to page 1.
|
||||||
|
|
||||||
|
export type SortDir = 'asc' | 'desc';
|
||||||
|
export type SortValue = string | number | boolean | null | undefined;
|
||||||
|
export type Accessor<T> = (row: T) => SortValue;
|
||||||
|
|
||||||
|
const PAGE_SIZES = [10, 25, 50, 100] as const;
|
||||||
|
|
||||||
|
function isEmpty(value: SortValue): boolean {
|
||||||
|
return value === null || value === undefined || value === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNonEmpty(a: SortValue, b: SortValue): number {
|
||||||
|
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||||
|
if (typeof a === 'boolean' && typeof b === 'boolean') {
|
||||||
|
return a === b ? 0 : a ? -1 : 1;
|
||||||
|
}
|
||||||
|
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TableController<T> {
|
||||||
|
readonly pageSizes = PAGE_SIZES;
|
||||||
|
|
||||||
|
page = $state(1);
|
||||||
|
pageSize = $state(25);
|
||||||
|
sortKey = $state<string | null>(null);
|
||||||
|
sortDir = $state<SortDir>('asc');
|
||||||
|
|
||||||
|
private readonly source: () => readonly T[];
|
||||||
|
private readonly accessors: Record<string, Accessor<T>>;
|
||||||
|
|
||||||
|
constructor(source: () => readonly T[], accessors: Record<string, Accessor<T>> = {}) {
|
||||||
|
this.source = source;
|
||||||
|
this.accessors = accessors;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly sorted = $derived.by<readonly T[]>(() => {
|
||||||
|
const rows = this.source();
|
||||||
|
const accessor = this.sortKey ? this.accessors[this.sortKey] : undefined;
|
||||||
|
if (!accessor) return rows;
|
||||||
|
const dir = this.sortDir === 'asc' ? 1 : -1;
|
||||||
|
// Empties always sort last, regardless of direction.
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const av = accessor(a);
|
||||||
|
const bv = accessor(b);
|
||||||
|
const aEmpty = isEmpty(av);
|
||||||
|
const bEmpty = isEmpty(bv);
|
||||||
|
if (aEmpty || bEmpty) {
|
||||||
|
if (aEmpty && bEmpty) return 0;
|
||||||
|
return aEmpty ? 1 : -1;
|
||||||
|
}
|
||||||
|
return compareNonEmpty(av, bv) * dir;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly total = $derived(this.sorted.length);
|
||||||
|
readonly totalPages = $derived(Math.max(1, Math.ceil(this.total / this.pageSize)));
|
||||||
|
// The page we actually show. Derived clamping means shrinking the result set
|
||||||
|
// (via filters or a larger page size) can never strand the view on an empty page.
|
||||||
|
readonly currentPage = $derived(Math.min(Math.max(1, this.page), this.totalPages));
|
||||||
|
readonly pageStart = $derived(this.total === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1);
|
||||||
|
readonly pageEnd = $derived(Math.min(this.total, this.currentPage * this.pageSize));
|
||||||
|
readonly rows = $derived(this.sorted.slice(this.pageStart === 0 ? 0 : this.pageStart - 1, this.pageEnd));
|
||||||
|
readonly canPrev = $derived(this.currentPage > 1);
|
||||||
|
readonly canNext = $derived(this.currentPage < this.totalPages);
|
||||||
|
|
||||||
|
toggleSort(key: string): void {
|
||||||
|
if (!this.accessors[key]) return;
|
||||||
|
if (this.sortKey === key) {
|
||||||
|
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
this.sortKey = key;
|
||||||
|
this.sortDir = 'asc';
|
||||||
|
}
|
||||||
|
this.page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPage(next: number): void {
|
||||||
|
this.page = Math.min(Math.max(1, next), this.totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
next(): void {
|
||||||
|
this.setPage(this.currentPage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
prev(): void {
|
||||||
|
this.setPage(this.currentPage - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.page = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,7 @@ export type MixCalculatorLine = {
|
|||||||
required_kg: number;
|
required_kg: number;
|
||||||
mix_percentage: number;
|
mix_percentage: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
|
rounding_decimals?: number;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function load({ fetch }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows, rawMaterials] = await Promise.all([
|
const [rows, rawMaterials] = await Promise.all([
|
||||||
api.editorProducts({ limit: 1000 }, fetch),
|
api.editorMixes({ limit: 1000 }, fetch),
|
||||||
api.rawMaterials(fetch)
|
api.rawMaterials(fetch)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||||
|
import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
|
export async function load({ fetch }) {
|
||||||
|
if (!hasStoredClientSession()) {
|
||||||
|
return { ingredients: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getStoredClientSession();
|
||||||
|
if (!canOpenEditor(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ingredients = await api.editorIngredients(fetch);
|
||||||
|
return { ingredients };
|
||||||
|
} catch {
|
||||||
|
return { ingredients: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import '$lib/ordering/manage.css';
|
||||||
|
import { findOrderingSection } from '$lib/navigation/client-navigation';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
// The header mirrors the active section: "Order Management" eyebrow above, then
|
||||||
|
// the current page's name (Orders, Products, …, or a nested page like Xero).
|
||||||
|
// Reuse the rail's section finder so the title stays in sync with navigation.
|
||||||
|
const sectionLabel = $derived(findOrderingSection(page.url.pathname)?.label ?? 'Orders');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Section navigation lives in the primary left rail (and the mobile drawer),
|
||||||
|
so the console pages don't repeat it inline. -->
|
||||||
|
<div class="manage-shell">
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">Order Management</p>
|
||||||
|
<h1>{sectionLabel}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.manage-shell { display: grid; gap: 1rem; }
|
||||||
|
h1 { margin: 0.15rem 0; font-size: 1.4rem; letter-spacing: -0.02em; }
|
||||||
|
.eyebrow { color: var(--color-text-muted); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||||
|
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||||
|
|
||||||
|
// Single access guard for the whole Order Management family. Each child route
|
||||||
|
// (orders, products, customers, pricing, settings, integrations) loads its own
|
||||||
|
// data; this layout just keeps non-managers out of all of them in one place.
|
||||||
|
export async function load() {
|
||||||
|
if (!hasStoredClientSession()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getStoredClientSession();
|
||||||
|
if (!canManageOrdering(session)) {
|
||||||
|
throw redirect(307, getWorkspaceHomeHref(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
@@ -1,59 +1,16 @@
|
|||||||
<script lang="ts">
|
<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>
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -617,7 +617,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
color: #7f8e85;
|
color: var(--color-text-muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
@@ -680,14 +680,14 @@
|
|||||||
|
|
||||||
.feedback.success {
|
.feedback.success {
|
||||||
color: var(--green-deep);
|
color: var(--green-deep);
|
||||||
border-color: #d8ecdf;
|
border-color: color-mix(in srgb, var(--color-success) 22%, var(--color-border));
|
||||||
background: #f6fcf8;
|
background: color-mix(in srgb, var(--color-success-tint) 55%, var(--color-bg-surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.feedback.error {
|
.feedback.error {
|
||||||
color: #a03737;
|
color: var(--color-error);
|
||||||
border-color: #f0d9d9;
|
border-color: color-mix(in srgb, var(--color-error) 22%, var(--color-border));
|
||||||
background: #fff8f8;
|
background: color-mix(in srgb, var(--color-error) 8%, var(--color-bg-surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-row,
|
.metric-row,
|
||||||
@@ -839,7 +839,7 @@
|
|||||||
label {
|
label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
color: #53645b;
|
color: var(--color-text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -860,7 +860,7 @@
|
|||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.9rem;
|
border-radius: 0.9rem;
|
||||||
color: #fff;
|
color: var(--color-on-brand);
|
||||||
background: var(--color-brand);
|
background: var(--color-brand);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -955,13 +955,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.material-icon.active {
|
.material-icon.active {
|
||||||
color: #fff;
|
color: var(--color-on-brand);
|
||||||
background: var(--color-brand);
|
background: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icon.muted {
|
.material-icon.muted {
|
||||||
color: #55685f;
|
color: var(--color-text-secondary);
|
||||||
background: #e9efeb;
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
@@ -981,8 +981,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-pill.neutral {
|
.status-pill.neutral {
|
||||||
color: #5a6c63;
|
color: var(--color-text-secondary);
|
||||||
background: #edf2ef;
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-grid {
|
.material-grid {
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
|
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
|
||||||
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
||||||
import { clientSession } from '$lib/session';
|
import { clientSession } from '$lib/session';
|
||||||
|
import { canEditThroughput } from '$lib/workspace-access';
|
||||||
import { toast } from '$lib/toast';
|
import { toast } from '$lib/toast';
|
||||||
import { CircleUserRound, LockKeyhole } from 'lucide-svelte';
|
import type { ThroughputImportResult } from '$lib/types';
|
||||||
|
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte';
|
||||||
|
|
||||||
type Section = 'profile' | 'security';
|
type Section = 'profile' | 'security' | 'import';
|
||||||
let activeSection = $state<Section>('profile');
|
let activeSection = $state<Section>('profile');
|
||||||
|
|
||||||
|
// Only operators who can edit throughput see (and can use) the import tool.
|
||||||
|
const canImportThroughput = $derived(canEditThroughput($clientSession));
|
||||||
|
|
||||||
let name = $state($clientSession?.name ?? '');
|
let name = $state($clientSession?.name ?? '');
|
||||||
let email = $state($clientSession?.email ?? '');
|
let email = $state($clientSession?.email ?? '');
|
||||||
|
|
||||||
@@ -69,6 +74,90 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Throughput import ─────────────────────────────────────────
|
||||||
|
let importFile = $state<File | null>(null);
|
||||||
|
let importing = $state(false);
|
||||||
|
let importResult = $state<ThroughputImportResult | null>(null);
|
||||||
|
let importError = $state('');
|
||||||
|
|
||||||
|
function onImportFileChange(event: Event) {
|
||||||
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
|
importFile = input.files?.[0] ?? null;
|
||||||
|
importResult = null;
|
||||||
|
importError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImport() {
|
||||||
|
if (!importFile) {
|
||||||
|
importError = 'Choose a CSV or spreadsheet file first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
importing = true;
|
||||||
|
importError = '';
|
||||||
|
importResult = null;
|
||||||
|
const tid = toast.loading('Importing entries…');
|
||||||
|
try {
|
||||||
|
const result = await api.importThroughputEntries(importFile);
|
||||||
|
importResult = result;
|
||||||
|
toast.dismiss(tid);
|
||||||
|
if (result.entries_imported > 0) {
|
||||||
|
toast.success(
|
||||||
|
`Imported ${result.entries_imported} ${result.entries_imported === 1 ? 'entry' : 'entries'}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error('No entries were imported. Check the file and try again.');
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.dismiss(tid);
|
||||||
|
const msg = err instanceof Error ? err.message : 'Import failed';
|
||||||
|
importError = msg;
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
importing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a small sample CSV in the browser so operators have a working header
|
||||||
|
// row to copy from. The backend matches these headers case-insensitively.
|
||||||
|
function downloadTemplate() {
|
||||||
|
const headers = [
|
||||||
|
'Date',
|
||||||
|
'Product',
|
||||||
|
'Item ID',
|
||||||
|
'Quantity',
|
||||||
|
'Type',
|
||||||
|
'Bag Size',
|
||||||
|
'Packed By',
|
||||||
|
'For Order',
|
||||||
|
'Job Number',
|
||||||
|
'For Stock',
|
||||||
|
'Stock Quantity',
|
||||||
|
'Notes'
|
||||||
|
];
|
||||||
|
const example = [
|
||||||
|
'2026-06-12',
|
||||||
|
'Specialty Pigeon Breeder',
|
||||||
|
'',
|
||||||
|
'40',
|
||||||
|
'bags',
|
||||||
|
'20',
|
||||||
|
'Jane Doe',
|
||||||
|
'yes',
|
||||||
|
'JOB1234',
|
||||||
|
'no',
|
||||||
|
'',
|
||||||
|
'First run of the day'
|
||||||
|
];
|
||||||
|
const csv = `${headers.join(',')}\n${example.join(',')}\n`;
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'throughput-import-template.csv';
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
const initials = $derived(
|
const initials = $derived(
|
||||||
($clientSession?.name ?? '')
|
($clientSession?.name ?? '')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
@@ -78,12 +167,13 @@
|
|||||||
.toUpperCase() || '?'
|
.toUpperCase() || '?'
|
||||||
);
|
);
|
||||||
|
|
||||||
const navItems: { id: Section; label: string; icon: typeof CircleUserRound }[] = [
|
const navItems = $derived<{ id: Section; label: string; icon: typeof CircleUserRound }[]>([
|
||||||
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
|
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
|
||||||
{ id: 'security', label: 'Security', icon: LockKeyhole },
|
{ id: 'security', label: 'Security', icon: LockKeyhole },
|
||||||
];
|
...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
|
||||||
|
]);
|
||||||
|
|
||||||
const railGroups = [{ items: navItems }];
|
const railGroups = $derived([{ items: navItems }]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AppSecondaryRailLayout>
|
<AppSecondaryRailLayout>
|
||||||
@@ -164,6 +254,92 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{:else if activeSection === 'import' && canImportThroughput}
|
||||||
|
<div class="panel-section">
|
||||||
|
<header class="panel-header">
|
||||||
|
<h2>Import throughput entries</h2>
|
||||||
|
<p>Upload a CSV or Excel (.xlsx) file of packing runs. Each row is saved as a throughput entry.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="import-body">
|
||||||
|
<div class="import-help">
|
||||||
|
<h3>Required columns</h3>
|
||||||
|
<p>
|
||||||
|
Your file needs a header row with at least <strong>Date</strong>, <strong>Product</strong>
|
||||||
|
and <strong>Quantity</strong> columns. These optional columns are also recognised:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Type</strong> — <code>bags</code> or <code>kg</code> (inferred from bag size if omitted)</li>
|
||||||
|
<li><strong>Bag Size</strong> — kg per bag (required when packing as bags)</li>
|
||||||
|
<li><strong>Item ID</strong> — matches an existing product; otherwise matched by name</li>
|
||||||
|
<li><strong>Packed By</strong>, <strong>Notes</strong></li>
|
||||||
|
<li><strong>For Order</strong>, <strong>Job Number</strong>, <strong>For Stock</strong>, <strong>Stock Quantity</strong></li>
|
||||||
|
</ul>
|
||||||
|
<p class="import-note">
|
||||||
|
Products that don't already exist are created automatically. Dates accept
|
||||||
|
<code>YYYY-MM-DD</code> or <code>DD/MM/YYYY</code>.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn-link" onclick={downloadTemplate}>
|
||||||
|
<FileSpreadsheet size={15} strokeWidth={2.2} /> Download CSV template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="import-control">
|
||||||
|
<label class="file-drop" class:has-file={!!importFile}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.xlsx,.xlsm,.xls,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
onchange={onImportFileChange}
|
||||||
|
/>
|
||||||
|
<Upload size={22} strokeWidth={2} />
|
||||||
|
<span class="file-drop-label">
|
||||||
|
{importFile ? importFile.name : 'Choose a CSV or .xlsx file'}
|
||||||
|
</span>
|
||||||
|
{#if importFile}
|
||||||
|
<span class="file-drop-size">{(importFile.size / 1024).toFixed(1)} KB</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if importError}
|
||||||
|
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {importError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if importResult}
|
||||||
|
<div class="import-result" role="status">
|
||||||
|
<p class="import-result-head">
|
||||||
|
Imported <strong>{importResult.entries_imported}</strong>
|
||||||
|
{importResult.entries_imported === 1 ? 'entry' : 'entries'}.
|
||||||
|
</p>
|
||||||
|
<ul class="import-result-stats">
|
||||||
|
{#if importResult.products_created > 0}
|
||||||
|
<li>{importResult.products_created} new product{importResult.products_created === 1 ? '' : 's'} created</li>
|
||||||
|
{/if}
|
||||||
|
{#if importResult.entries_skipped > 0}
|
||||||
|
<li>{importResult.entries_skipped} row{importResult.entries_skipped === 1 ? '' : 's'} skipped</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{#if importResult.errors.length > 0}
|
||||||
|
<details class="import-errors">
|
||||||
|
<summary>{importResult.errors.length} issue{importResult.errors.length === 1 ? '' : 's'} to review</summary>
|
||||||
|
<ul>
|
||||||
|
{#each importResult.errors as err (err)}
|
||||||
|
<li>{err}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<button class="btn-primary" type="button" disabled={importing || !importFile} onclick={runImport}>
|
||||||
|
{importing ? 'Importing…' : 'Import entries'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</AppSecondaryRailLayout>
|
</AppSecondaryRailLayout>
|
||||||
@@ -288,11 +464,173 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Import ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.import-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-help h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-help p {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-help ul {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-help code {
|
||||||
|
padding: 0.05rem 0.32rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-note {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-control {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2rem 1.25rem;
|
||||||
|
border: 1.5px dashed var(--line);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 140ms ease, color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop:hover {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop.has-file {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-label {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop-size {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-result {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-brand) 25%, transparent);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: color-mix(in srgb, var(--color-brand) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-result-head {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-result-stats {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-errors {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-errors summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-errors ul {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ─────────────────────────────────────────────── */
|
/* ── Responsive ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.field-row {
|
.field-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import { canOpenThroughput, getWorkspaceHomeHref } from '$lib/workspace-access';
|
|||||||
|
|
||||||
export async function load({ fetch }) {
|
export async function load({ fetch }) {
|
||||||
if (!hasStoredClientSession()) {
|
if (!hasStoredClientSession()) {
|
||||||
return { entries: [], products: [] };
|
return { entries: [], statsEntries: [], products: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getStoredClientSession();
|
const session = getStoredClientSession();
|
||||||
@@ -18,13 +18,22 @@ export async function load({ fetch }) {
|
|||||||
recentFrom.setDate(recentFrom.getDate() - 30);
|
recentFrom.setDate(recentFrom.getDate() - 30);
|
||||||
const dateFrom = recentFrom.toISOString().slice(0, 10);
|
const dateFrom = recentFrom.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// The overview cards are computed in the browser: the hero cards (today /
|
||||||
|
// this week / 4-week average) plus the customer-mix cards whose rolling range
|
||||||
|
// the operator can switch up to 12 weeks. Pull that widest window once so
|
||||||
|
// changing range is a pure re-filter with no extra request.
|
||||||
|
const statsFrom = new Date();
|
||||||
|
statsFrom.setDate(statsFrom.getDate() - 84);
|
||||||
|
const statsDateFrom = statsFrom.toISOString().slice(0, 10);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [entries, products] = await Promise.all([
|
const [entries, statsEntries, products] = await Promise.all([
|
||||||
api.throughputEntries({ date_from: dateFrom, limit: 200 }, fetch),
|
api.throughputEntries({ date_from: dateFrom, limit: 200 }, fetch),
|
||||||
|
api.throughputEntries({ date_from: statsDateFrom, limit: 1000 }, fetch),
|
||||||
api.throughputProducts(fetch)
|
api.throughputProducts(fetch)
|
||||||
]);
|
]);
|
||||||
return { entries, products };
|
return { entries, statsEntries, products };
|
||||||
} catch {
|
} catch {
|
||||||
return { entries: [], products: [] };
|
return { entries: [], statsEntries: [], products: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user