v0.1.14 - b2b portal

This commit is contained in:
2026-06-11 23:56:02 +12:00
parent 349e4a4b5b
commit 4ff372d307
48 changed files with 5845 additions and 925 deletions
+23
View File
@@ -184,6 +184,29 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
return dependency return dependency
def require_ordering_admin_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
) -> AuthSession:
"""Internal-side authorization for managing the ordering portal.
Accepts the single Lean admin (``role == "admin"``) or an internal Hunter
user holding ``manage`` on the ordering module. Client/customer users are
rejected — they use the customer-facing ``require_client_module_access``
dependency instead.
"""
if session.role == "admin":
return session
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get("ordering"), "manage"):
log_security_event("authz.denied", role=session.role, module="ordering", access_level="manage")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires manage access")
return session
log_security_event("authz.denied", role=session.role, module="ordering", required="admin")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires internal admin access")
def require_client_access_manager_session( def require_client_access_manager_session(
session: AuthSession = Depends(get_auth_session), session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db), db: Session = Depends(get_db),
+390
View File
@@ -0,0 +1,390 @@
"""Customer-facing B2B ordering API.
Every endpoint is gated by the ``ordering`` module and scoped to the caller's
own company (``tenant_id`` + ``client_account_id``). Customers can never see
another company's catalogue visibility, prices, or orders.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.ordering import CatalogueProduct, Order, OrderLine
from app.schemas.ordering import (
DraftOrderCreate,
DraftOrderUpdate,
OrderLineInput,
OrderSubmitRequest,
)
from app.services import ordering_service as svc
from app.services.order_notifications import send_order_submitted_notifications
from app.services.order_pdf import OrderPdfUnavailableError, build_order_confirmation_pdf
from app.services.ordering_pricing import resolve_price
router = APIRouter(prefix="/api/ordering", tags=["ordering"])
# --- Helpers -----------------------------------------------------------------
def _require_active_customer(db: Session, session: AuthSession):
account = svc.get_customer_account(db, client_account_id=session.client_account_id)
svc.ensure_customer_active(account)
return account
def _get_visible_product(db: Session, session: AuthSession, product_id: int) -> CatalogueProduct:
hidden = svc.visible_product_ids_for_customer(
db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
)
product = db.scalar(
select(CatalogueProduct).where(
CatalogueProduct.id == product_id,
CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
CatalogueProduct.active.is_(True),
)
)
if product is None or product.id in hidden:
raise HTTPException(status_code=404, detail="Product not available")
return product
def _load_own_order(db: Session, session: AuthSession, order_id: int) -> Order:
order = db.scalar(
select(Order)
.where(
Order.id == order_id,
Order.tenant_id == svc.ORDERING_TENANT,
Order.client_account_id == session.client_account_id,
)
.options(selectinload(Order.lines))
)
if order is None:
raise HTTPException(status_code=404, detail="Order not found")
return order
def _rebuild_lines(db: Session, session: AuthSession, order: Order, lines: list[OrderLineInput]) -> None:
"""Replace an order's lines with freshly priced lines (server-side pricing).
Validates product availability and minimum order quantity. Raises 422 on
invalid input.
"""
order.lines.clear()
db.flush()
for index, line_input in enumerate(lines):
product = _get_visible_product(db, session, line_input.product_id)
if line_input.quantity < product.min_order_quantity:
raise HTTPException(
status_code=422,
detail=(
f"{product.name}: minimum order quantity is "
f"{product.min_order_quantity:g} {product.unit_of_measure}"
),
)
resolution = resolve_price(
db,
client_account_id=session.client_account_id,
product=product,
quantity=line_input.quantity,
)
order.lines.append(
OrderLine(
tenant_id=svc.ORDERING_TENANT,
product_id=product.id,
product_name=product.name,
product_sku=product.sku,
quantity=line_input.quantity,
unit_price=resolution.unit_price,
requires_quote=resolution.requires_quote,
price_source=resolution.price_source,
price_rule_id=resolution.price_rule_id,
discount_percent=resolution.discount_percent,
sort_order=index,
notes=line_input.notes,
)
)
db.flush()
svc.recompute_order_totals(order)
# --- Catalogue ---------------------------------------------------------------
@router.get("/catalogue")
def list_catalogue(
category: str | None = Query(default=None),
q: str | None = Query(default=None),
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
products = svc.list_visible_products(
db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
)
if category:
products = [p for p in products if p.category == category]
if q:
needle = q.lower()
products = [
p
for p in products
if needle in p.name.lower()
or needle in p.sku.lower()
or (p.description and needle in p.description.lower())
]
return [
svc.serialize_product(p, db=db, client_account_id=session.client_account_id)
for p in products
]
@router.get("/catalogue/{product_id}")
def get_catalogue_product(
product_id: int,
quantity: float = Query(default=1.0, gt=0),
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
product = _get_visible_product(db, session, product_id)
return svc.serialize_product(
product, db=db, client_account_id=session.client_account_id, quantity=quantity
)
# --- Orders ------------------------------------------------------------------
@router.get("/orders")
def list_orders(
status_filter: str | None = Query(default=None, alias="status"),
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
stmt = (
select(Order)
.where(
Order.tenant_id == svc.ORDERING_TENANT,
Order.client_account_id == session.client_account_id,
)
.options(selectinload(Order.lines))
.order_by(Order.created_at.desc())
)
orders = db.scalars(stmt).all()
if status_filter == "draft":
orders = [o for o in orders if o.status == "draft"]
elif status_filter == "submitted":
orders = [o for o in orders if o.status != "draft"]
return [svc.serialize_order(o, for_admin=False) for o in orders]
@router.get("/orders/{order_id}")
def get_order(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
return svc.serialize_order(order, for_admin=False)
@router.post("/orders", status_code=status.HTTP_201_CREATED)
def create_draft_order(
payload: DraftOrderCreate,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
order = Order(
tenant_id=svc.ORDERING_TENANT,
client_account_id=session.client_account_id,
status="draft",
created_by_user_id=session.user_id,
created_by_name=session.name,
purchase_order_number=payload.purchase_order_number,
delivery_notes=payload.delivery_notes,
requested_delivery_date=payload.requested_delivery_date,
fulfilment_method=payload.fulfilment_method,
)
db.add(order)
db.flush()
_rebuild_lines(db, session, order, payload.lines)
svc.record_status_change(
db, order, to_status="draft", actor_type="customer", actor_name=session.name, note="Draft created"
)
svc.audit_order_event(
db, session=session, order=order, action="order.created", summary="Draft order created."
)
db.commit()
db.refresh(order)
return svc.serialize_order(order, for_admin=False)
@router.patch("/orders/{order_id}")
def update_draft_order(
order_id: int,
payload: DraftOrderUpdate,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
if order.status not in svc.CUSTOMER_EDITABLE_STATUSES:
raise HTTPException(
status_code=409,
detail="This order has been submitted and can no longer be edited. Ask an admin to reopen it.",
)
changes = payload.model_dump(exclude_unset=True)
for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
if field_name in changes:
setattr(order, field_name, changes[field_name])
if payload.lines is not None:
_rebuild_lines(db, session, order, payload.lines)
svc.audit_order_event(
db, session=session, order=order, action="order.updated", summary="Draft order updated."
)
db.commit()
db.refresh(order)
return svc.serialize_order(order, for_admin=False)
@router.delete("/orders/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_draft_order(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
if order.status != "draft":
raise HTTPException(status_code=409, detail="Only draft orders can be deleted")
db.delete(order)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/orders/{order_id}/submit")
def submit_order(
order_id: int,
payload: OrderSubmitRequest,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
account = _require_active_customer(db, session)
order = _load_own_order(db, session, order_id)
if order.status != "draft":
raise HTTPException(status_code=409, detail="Only draft orders can be submitted")
if not order.lines:
raise HTTPException(status_code=422, detail="Cannot submit an empty order")
# Apply any last-minute header changes supplied on submit.
changes = payload.model_dump(exclude_unset=True)
for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
if field_name in changes and changes[field_name] is not None:
setattr(order, field_name, changes[field_name])
# Required PO number, if the tenant configured it.
from app.services.order_notifications import get_or_create_settings
settings = get_or_create_settings(db, order.tenant_id)
if settings.require_po_number and not (order.purchase_order_number or "").strip():
raise HTTPException(status_code=422, detail="A purchase order number is required to submit this order")
# Re-resolve and freeze the exact price used at submission time.
line_inputs = [
OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
for line in order.lines
]
_rebuild_lines(db, session, order, line_inputs)
order.order_number = svc.next_order_number(db, order.tenant_id)
order.submitted_at = datetime.utcnow()
svc.record_status_change(
db, order, to_status="submitted", actor_type="customer", actor_name=session.name, note="Submitted by customer"
)
svc.audit_order_event(
db, session=session, order=order, action="order.submitted",
summary=f"Order {order.order_number} submitted (subtotal ex GST {order.subtotal_ex_gst:.2f}).",
)
notifications = send_order_submitted_notifications(db, order)
db.commit()
db.refresh(order)
result = svc.serialize_order(order, for_admin=False)
result["notifications"] = [
{"channel": n.channel, "recipients": n.recipients, "delivered": n.delivered, "detail": n.detail}
for n in notifications
]
return result
@router.post("/orders/{order_id}/reorder", status_code=status.HTTP_201_CREATED)
def reorder(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
db: Session = Depends(get_db),
):
_require_active_customer(db, session)
source = _load_own_order(db, session, order_id)
new_order = Order(
tenant_id=svc.ORDERING_TENANT,
client_account_id=session.client_account_id,
status="draft",
created_by_user_id=session.user_id,
created_by_name=session.name,
fulfilment_method=source.fulfilment_method,
)
db.add(new_order)
db.flush()
line_inputs = [
OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
for line in source.lines
]
# Skip lines whose product is no longer available rather than failing.
available: list[OrderLineInput] = []
for line_input in line_inputs:
product = db.scalar(
select(CatalogueProduct).where(
CatalogueProduct.id == line_input.product_id,
CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
CatalogueProduct.active.is_(True),
)
)
if product is not None and line_input.quantity >= product.min_order_quantity:
available.append(line_input)
_rebuild_lines(db, session, new_order, available)
svc.record_status_change(
db, new_order, to_status="draft", actor_type="customer", actor_name=session.name,
note=f"Reordered from {source.order_number or source.id}",
)
svc.audit_order_event(
db, session=session, order=new_order, action="order.reordered",
summary=f"Draft created by reordering from {source.order_number or source.id}.",
)
db.commit()
db.refresh(new_order)
return svc.serialize_order(new_order, for_admin=False)
@router.get("/orders/{order_id}/confirmation.pdf")
def order_confirmation_pdf(
order_id: int,
session: AuthSession = Depends(require_client_module_access("ordering")),
db: Session = Depends(get_db),
):
order = _load_own_order(db, session, order_id)
account = svc.get_customer_account(db, client_account_id=session.client_account_id)
try:
pdf_bytes = build_order_confirmation_pdf(order, account)
except OrderPdfUnavailableError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
filename = f"order-{order.order_number or order.id}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
File diff suppressed because it is too large Load Diff
+3
View File
@@ -53,6 +53,9 @@ _PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
"edit_mixes": ("mix_master", "edit"), "edit_mixes": ("mix_master", "edit"),
"view_throughput": ("operations_throughput", "view"), "view_throughput": ("operations_throughput", "view"),
"edit_throughput": ("operations_throughput", "edit"), "edit_throughput": ("operations_throughput", "edit"),
"view_ordering": ("ordering", "view"),
"edit_ordering": ("ordering", "edit"),
"manage_ordering": ("ordering", "manage"),
"view_scenarios": ("scenarios", "view"), "view_scenarios": ("scenarios", "view"),
"edit_scenarios": ("scenarios", "edit"), "edit_scenarios": ("scenarios", "edit"),
"manage_client_access": ("client_access", "manage"), "manage_client_access": ("client_access", "manage"),
+15
View File
@@ -42,6 +42,21 @@ TENANT_TABLES = {
"freight_cost_rules": None, "freight_cost_rules": None,
"throughput_products": None, "throughput_products": None,
"production_throughput_entries": None, "production_throughput_entries": None,
# B2B ordering portal
"product_categories": None,
"catalogue_products": None,
"customer_product_visibility": None,
"price_lists": None,
"price_list_items": None,
"customer_price_assignments": None,
"customer_product_prices": None,
"price_tiers": None,
"orders": None,
"order_lines": None,
"order_status_history": None,
"order_attachments": None,
"notification_settings": None,
"xero_sync_log": None,
} }
+4
View File
@@ -25,6 +25,8 @@ from app.api.dashboard import router as dashboard_router
from app.api.editor import router as editor_router from app.api.editor import router as editor_router
from app.api.mix_calculator import router as mix_calculator_router from app.api.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_router from app.api.mixes import router as mixes_router
from app.api.ordering import router as ordering_router
from app.api.ordering_admin import router as ordering_admin_router
from app.api.powerbi import router as powerbi_router from app.api.powerbi import router as powerbi_router
from app.api.product_costing import router as product_costing_router from app.api.product_costing import router as product_costing_router
from app.api.products import router as products_router from app.api.products import router as products_router
@@ -204,6 +206,8 @@ app.include_router(product_costing_router)
app.include_router(products_router) app.include_router(products_router)
app.include_router(scenarios_router) app.include_router(scenarios_router)
app.include_router(throughput_router) app.include_router(throughput_router)
app.include_router(ordering_router)
app.include_router(ordering_admin_router)
app.include_router(powerbi_router) app.include_router(powerbi_router)
+30
View File
@@ -3,6 +3,22 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCos
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.ordering import (
CatalogueProduct,
CustomerPriceAssignment,
CustomerProductPrice,
CustomerProductVisibility,
NotificationSetting,
Order,
OrderAttachment,
OrderLine,
OrderStatusHistory,
PriceList,
PriceListItem,
PriceTier,
ProductCategory,
XeroSyncLog,
)
from app.models.product import Product, ProductIngredient from app.models.product import Product, ProductIngredient
from app.models.product_costing import ( from app.models.product_costing import (
ProductCostBagInput, ProductCostBagInput,
@@ -17,14 +33,28 @@ from app.models.scenario import CostingResult, Scenario
from app.models.throughput import ProductionThroughput, ThroughputProduct from app.models.throughput import ProductionThroughput, ThroughputProduct
__all__ = [ __all__ = [
"CatalogueProduct",
"ClientAccount", "ClientAccount",
"ClientAccessAuditEvent", "ClientAccessAuditEvent",
"ClientFeatureAccess", "ClientFeatureAccess",
"ClientUser", "ClientUser",
"ClientUserModulePermission", "ClientUserModulePermission",
"CostingResult", "CostingResult",
"CustomerPriceAssignment",
"CustomerProductPrice",
"CustomerProductVisibility",
"FreightCostRule", "FreightCostRule",
"Mix", "Mix",
"NotificationSetting",
"Order",
"OrderAttachment",
"OrderLine",
"OrderStatusHistory",
"PriceList",
"PriceListItem",
"PriceTier",
"ProductCategory",
"XeroSyncLog",
"MixCalculatorSession", "MixCalculatorSession",
"MixCalculatorSessionLine", "MixCalculatorSessionLine",
"MixIngredient", "MixIngredient",
+390
View File
@@ -0,0 +1,390 @@
"""B2B ordering portal data model.
This module backs the private customer ordering portal. It deliberately reuses
the existing tenant/customer primitives rather than introducing parallel ones:
* A **customer/company** is an existing :class:`ClientAccount`.
* A **customer user** is an existing :class:`ClientUser` (owner/buyer/viewer/
accounts roles map onto the existing ``ordering`` module access levels).
Everything here is private: every row is tenant-scoped, and customer-facing
rows are additionally scoped to a ``client_account_id``. The catalogue itself is
global to the seller (one tenant), with per-customer visibility and pricing
layered on top.
Money is stored GST-exclusive (see ``CLAUDE.MD`` costing conventions) as floats,
matching the rest of the costing platform.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
# --- Controlled vocabularies -------------------------------------------------
# Plain string constants (not DB enums) keep SQLite migrations trivial and match
# the existing model style.
PRODUCT_CATEGORIES = (
"grains",
"premixed",
"bags",
"bulk_loads",
"custom_blends",
"services",
)
# Full internal order lifecycle. Customers see a simplified subset (see
# ``CUSTOMER_VISIBLE_STATUS`` in the ordering service).
ORDER_STATUSES = (
"draft",
"submitted",
"under_review",
"confirmed",
"sent_to_xero",
"in_production",
"ready_for_pickup",
"dispatched",
"completed",
"cancelled",
)
# How a resolved unit price was derived. Stored on each order line so future
# reporting can always explain which rule applied.
PRICE_SOURCES = (
"fixed", # CustomerProductPrice with rule_type fixed
"contract", # CustomerProductPrice with rule_type contract
"price_list", # PriceListItem via an assigned price list
"tiered", # a quantity tier won over the base rate
"base", # catalogue list price (optionally with customer discount)
"quote", # manual quote required; no automatic price
)
FULFILMENT_METHODS = ("delivery", "pickup")
class ProductCategory(Base):
__tablename__ = "product_categories"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_product_category_slug"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
slug: Mapped[str] = mapped_column(String(64), index=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class CatalogueProduct(Base):
"""A private catalogue product. Named ``CatalogueProduct`` to avoid clashing
with the existing costing ``Product`` (a separate concern)."""
__tablename__ = "catalogue_products"
__table_args__ = (UniqueConstraint("tenant_id", "sku", name="uq_catalogue_product_sku"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
name: Mapped[str] = mapped_column(String(255), index=True)
sku: Mapped[str] = mapped_column(String(64), index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
category: Mapped[str] = mapped_column(String(64), default="grains", index=True)
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
unit_size: Mapped[str | None] = mapped_column(String(64), nullable=True)
unit_of_measure: Mapped[str] = mapped_column(String(64), default="each")
min_order_quantity: Mapped[float] = mapped_column(Float, default=1.0)
# Base/list price, GST-exclusive. Used as the fallback price source and as
# the basis for a customer discount percentage.
base_price: Mapped[float | None] = mapped_column(Float, nullable=True)
stock_status: Mapped[str] = mapped_column(String(32), default="in_stock")
active: Mapped[bool] = mapped_column(Boolean, default=True)
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class CustomerProductVisibility(Base):
"""Per-customer override of catalogue visibility.
Products are visible to every customer by default (the catalogue is
"standard globally"). A row here with ``visible=False`` hides a product from
one customer; ``visible=True`` is an explicit allow (no-op unless a future
default flips to opt-in).
"""
__tablename__ = "customer_product_visibility"
__table_args__ = (
UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_visibility"),
)
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)
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
visible: Mapped[bool] = mapped_column(Boolean, default=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class PriceList(Base):
__tablename__ = "price_lists"
__table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_price_list_code"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
code: Mapped[str] = mapped_column(String(64), index=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
items: Mapped[list["PriceListItem"]] = relationship(
back_populates="price_list",
cascade="all, delete-orphan",
)
class PriceListItem(Base):
__tablename__ = "price_list_items"
__table_args__ = (UniqueConstraint("price_list_id", "product_id", name="uq_price_list_item"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
price_list_id: Mapped[int] = mapped_column(ForeignKey("price_lists.id"), index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
unit_price: Mapped[float] = mapped_column(Float)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
price_list: Mapped[PriceList] = relationship(back_populates="items")
class CustomerPriceAssignment(Base):
"""Links a customer to an assigned price list and a default discount."""
__tablename__ = "customer_price_assignments"
__table_args__ = (
UniqueConstraint("client_account_id", name="uq_customer_price_assignment"),
)
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)
price_list_id: Mapped[int | None] = mapped_column(ForeignKey("price_lists.id"), nullable=True, index=True)
# Default percentage discount applied to base prices for this customer when
# no more specific rule (fixed/contract/price-list) applies. 0..100.
discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
price_list: Mapped[PriceList | None] = relationship()
class CustomerProductPrice(Base):
"""A fixed or contract price for a specific customer + product. Highest
priority pricing rule."""
__tablename__ = "customer_product_prices"
__table_args__ = (
UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_price"),
)
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)
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
# "fixed" | "contract" | "quote" (quote forces a manual-quote workflow for
# this customer+product even if the product itself doesn't require one).
rule_type: Mapped[str] = mapped_column(String(32), default="fixed")
contract_reference: Mapped[str | None] = mapped_column(String(128), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class PriceTier(Base):
"""Quantity break for a pricing source. Exactly one of
``customer_product_price_id`` / ``price_list_item_id`` is set."""
__tablename__ = "price_tiers"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
customer_product_price_id: Mapped[int | None] = mapped_column(
ForeignKey("customer_product_prices.id"), nullable=True, index=True
)
price_list_item_id: Mapped[int | None] = mapped_column(
ForeignKey("price_list_items.id"), nullable=True, index=True
)
# Tier applies when ordered quantity >= min_quantity. The highest qualifying
# min_quantity wins.
min_quantity: Mapped[float] = mapped_column(Float)
unit_price: Mapped[float] = mapped_column(Float)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Order(Base):
__tablename__ = "orders"
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)
# Human-friendly order reference, assigned on submit (e.g. ORD-000123).
order_number: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(32), default="draft", index=True)
# Who created / submitted it (ClientUser ids; nullable for admin-created).
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("client_users.id"), nullable=True)
created_by_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Customer-supplied order details.
purchase_order_number: Mapped[str | None] = mapped_column(String(128), nullable=True)
delivery_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
requested_delivery_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
fulfilment_method: Mapped[str] = mapped_column(String(32), default="delivery")
# Admin/internal handling.
admin_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
reopened: Mapped[bool] = mapped_column(Boolean, default=False)
# Cached totals (authoritative figures live on lines; this is for listing).
subtotal_ex_gst: Mapped[float] = mapped_column(Float, default=0.0)
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
# Xero integration tracking.
xero_status: Mapped[str] = mapped_column(String(32), default="not_sent")
xero_invoice_id: Mapped[str | None] = mapped_column(String(128), 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)
lines: Mapped[list["OrderLine"]] = relationship(
back_populates="order",
cascade="all, delete-orphan",
order_by="OrderLine.sort_order",
)
status_history: Mapped[list["OrderStatusHistory"]] = relationship(
back_populates="order",
cascade="all, delete-orphan",
order_by="OrderStatusHistory.created_at",
)
attachments: Mapped[list["OrderAttachment"]] = relationship(
back_populates="order",
cascade="all, delete-orphan",
)
class OrderLine(Base):
__tablename__ = "order_lines"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
# Snapshot of product identity at order time (so later catalogue edits don't
# rewrite historical orders).
product_name: Mapped[str] = mapped_column(String(255))
product_sku: Mapped[str] = mapped_column(String(64))
quantity: Mapped[float] = mapped_column(Float, default=1.0)
# Authoritative unit price, GST-exclusive, computed by the backend pricing
# engine and frozen at submission. Null only while quote-only.
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
line_total: Mapped[float | None] = mapped_column(Float, nullable=True)
requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
# Pricing provenance — enough to explain which rule applied in reporting.
price_source: Mapped[str] = mapped_column(String(32), default="base")
price_rule_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
# Admin override of the resolved unit price before confirmation.
admin_override_price: Mapped[float | None] = mapped_column(Float, nullable=True)
admin_override_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
order: Mapped[Order] = relationship(back_populates="lines")
class OrderStatusHistory(Base):
__tablename__ = "order_status_history"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
to_status: Mapped[str] = mapped_column(String(32))
actor_type: Mapped[str] = mapped_column(String(32), default="system")
actor_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
order: Mapped[Order] = relationship(back_populates="status_history")
class OrderAttachment(Base):
__tablename__ = "order_attachments"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
# "purchase_order" | "confirmation_pdf" | "other"
kind: Mapped[str] = mapped_column(String(32), default="other")
filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
order: Mapped[Order] = relationship(back_populates="attachments")
class NotificationSetting(Base):
"""Per-tenant notification configuration for the ordering portal."""
__tablename__ = "notification_settings"
__table_args__ = (UniqueConstraint("tenant_id", name="uq_notification_settings_tenant"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
# Comma-separated internal recipients notified on new order submissions.
internal_recipients: Mapped[str | None] = mapped_column(Text, nullable=True)
send_customer_confirmation: Mapped[bool] = mapped_column(Boolean, default=True)
require_po_number: Mapped[bool] = mapped_column(Boolean, default=False)
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class XeroSyncLog(Base):
__tablename__ = "xero_sync_log"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
# "queued" | "success" | "failed"
status: Mapped[str] = mapped_column(String(32), default="queued")
request_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+302
View File
@@ -0,0 +1,302 @@
"""Request schemas for the B2B ordering portal.
Responses are returned as plain dicts (FastAPI encodes them); these models
constrain and validate *inputs* only.
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from app.models.ordering import (
FULFILMENT_METHODS,
ORDER_STATUSES,
PRODUCT_CATEGORIES,
)
_CATEGORY_SET = set(PRODUCT_CATEGORIES)
_STATUS_SET = set(ORDER_STATUSES)
_FULFILMENT_SET = set(FULFILMENT_METHODS)
_RULE_TYPES = {"fixed", "contract", "quote"}
_STOCK_STATUSES = {"in_stock", "low_stock", "out_of_stock", "made_to_order"}
# --- Customer ordering -------------------------------------------------------
class OrderLineInput(BaseModel):
product_id: int
quantity: float = Field(gt=0)
notes: str | None = None
class DraftOrderCreate(BaseModel):
lines: list[OrderLineInput] = Field(default_factory=list)
purchase_order_number: str | None = None
delivery_notes: str | None = None
requested_delivery_date: datetime | None = None
fulfilment_method: str = "delivery"
@field_validator("fulfilment_method")
@classmethod
def _check_fulfilment(cls, value: str) -> str:
if value not in _FULFILMENT_SET:
raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
return value
class DraftOrderUpdate(BaseModel):
lines: list[OrderLineInput] | None = None
purchase_order_number: str | None = None
delivery_notes: str | None = None
requested_delivery_date: datetime | None = None
fulfilment_method: str | None = None
@field_validator("fulfilment_method")
@classmethod
def _check_fulfilment(cls, value: str | None) -> str | None:
if value is not None and value not in _FULFILMENT_SET:
raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
return value
class OrderSubmitRequest(BaseModel):
purchase_order_number: str | None = None
delivery_notes: str | None = None
requested_delivery_date: datetime | None = None
fulfilment_method: str | None = None
@field_validator("fulfilment_method")
@classmethod
def _check_fulfilment(cls, value: str | None) -> str | None:
if value is not None and value not in _FULFILMENT_SET:
raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
return value
# --- Admin: catalogue --------------------------------------------------------
class CategoryCreate(BaseModel):
slug: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
sort_order: int = 0
active: bool = True
class CategoryUpdate(BaseModel):
name: str | None = None
description: str | None = None
sort_order: int | None = None
active: bool | None = None
class CatalogueProductCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
sku: str = Field(min_length=1, max_length=64)
description: str | None = None
category: str = "grains"
image_url: str | None = None
unit_size: str | None = None
unit_of_measure: str = "each"
min_order_quantity: float = Field(default=1.0, ge=0)
base_price: float | None = Field(default=None, ge=0)
stock_status: str = "in_stock"
active: bool = True
requires_quote: bool = False
@field_validator("category")
@classmethod
def _check_category(cls, value: str) -> str:
if value not in _CATEGORY_SET:
raise ValueError(f"category must be one of {sorted(_CATEGORY_SET)}")
return value
@field_validator("stock_status")
@classmethod
def _check_stock(cls, value: str) -> str:
if value not in _STOCK_STATUSES:
raise ValueError(f"stock_status must be one of {sorted(_STOCK_STATUSES)}")
return value
class CatalogueProductUpdate(BaseModel):
name: str | None = None
sku: str | None = None
description: str | None = None
category: str | None = None
image_url: str | None = None
unit_size: str | None = None
unit_of_measure: str | None = None
min_order_quantity: float | None = Field(default=None, ge=0)
base_price: float | None = Field(default=None, ge=0)
stock_status: str | None = None
active: bool | None = None
requires_quote: bool | None = None
@field_validator("category")
@classmethod
def _check_category(cls, value: str | None) -> str | None:
if value is not None and value not in _CATEGORY_SET:
raise ValueError(f"category must be one of {sorted(_CATEGORY_SET)}")
return value
@field_validator("stock_status")
@classmethod
def _check_stock(cls, value: str | None) -> str | None:
if value is not None and value not in _STOCK_STATUSES:
raise ValueError(f"stock_status must be one of {sorted(_STOCK_STATUSES)}")
return value
class VisibilityUpdate(BaseModel):
product_id: int
visible: bool
# --- Admin: pricing ----------------------------------------------------------
class PriceTierInput(BaseModel):
min_quantity: float = Field(gt=0)
unit_price: float = Field(ge=0)
class PriceListCreate(BaseModel):
code: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
active: bool = True
class PriceListItemUpsert(BaseModel):
product_id: int
unit_price: float = Field(ge=0)
tiers: list[PriceTierInput] | None = None
class CustomerPriceAssignmentUpsert(BaseModel):
price_list_id: int | None = None
discount_percent: float = Field(default=0.0, ge=0, le=100)
class CustomerProductPriceUpsert(BaseModel):
product_id: int
unit_price: float | None = Field(default=None, ge=0)
rule_type: str = "fixed"
contract_reference: str | None = None
notes: str | None = None
active: bool = True
tiers: list[PriceTierInput] | None = None
@field_validator("rule_type")
@classmethod
def _check_rule(cls, value: str) -> str:
if value not in _RULE_TYPES:
raise ValueError(f"rule_type must be one of {sorted(_RULE_TYPES)}")
return value
# --- Admin: orders -----------------------------------------------------------
class OrderStatusUpdate(BaseModel):
to_status: str
note: str | None = None
@field_validator("to_status")
@classmethod
def _check_status(cls, value: str) -> str:
if value not in _STATUS_SET:
raise ValueError(f"to_status must be one of {sorted(_STATUS_SET)}")
return value
class OrderLineOverride(BaseModel):
quantity: float | None = Field(default=None, gt=0)
unit_price: float | None = Field(default=None, ge=0)
reason: str | None = None
class OrderAdminNotesUpdate(BaseModel):
admin_notes: str | None = None
class ReopenOrderRequest(BaseModel):
note: str | None = None
# --- Admin: settings ---------------------------------------------------------
class NotificationSettingsUpdate(BaseModel):
internal_recipients: str | None = None
send_customer_confirmation: bool | None = None
require_po_number: bool | None = None
from_email: str | None = None
# --- Admin: customers & users ------------------------------------------------
_CUSTOMER_STATUSES = {"active", "disabled"}
_ORDERING_USER_ROLES = {"owner", "buyer", "accounts", "viewer"}
class CustomerCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
client_code: str = Field(min_length=1, max_length=64)
tenant_id: str | None = None
notes: str | None = None
class CustomerUpdate(BaseModel):
name: str | None = None
status: str | None = None
notes: str | None = None
@field_validator("status")
@classmethod
def _check_status(cls, value: str | None) -> str | None:
if value is not None and value not in _CUSTOMER_STATUSES:
raise ValueError(f"status must be one of {sorted(_CUSTOMER_STATUSES)}")
return value
class CustomerUserCreate(BaseModel):
full_name: str = Field(min_length=1, max_length=255)
email: str = Field(min_length=3, max_length=255)
role: str = "buyer"
@field_validator("role")
@classmethod
def _check_role(cls, value: str) -> str:
if value not in _ORDERING_USER_ROLES:
raise ValueError(f"role must be one of {sorted(_ORDERING_USER_ROLES)}")
return value
@field_validator("email")
@classmethod
def _normalize_email(cls, value: str) -> str:
return value.strip().lower()
class CustomerUserUpdate(BaseModel):
full_name: str | None = None
role: str | None = None
status: str | None = None
@field_validator("role")
@classmethod
def _check_role(cls, value: str | None) -> str | None:
if value is not None and value not in _ORDERING_USER_ROLES:
raise ValueError(f"role must be one of {sorted(_ORDERING_USER_ROLES)}")
return value
@field_validator("status")
@classmethod
def _check_status(cls, value: str | None) -> str | None:
if value is not None and value not in {"active", "invited", "suspended"}:
raise ValueError("status must be one of ['active', 'invited', 'suspended']")
return value
+17
View File
@@ -1285,6 +1285,23 @@ def seed_startup_basics():
logger.info("Product costing module seeded: %s", product_costing_report) logger.info("Product costing module seeded: %s", product_costing_report)
db.commit() db.commit()
# The ordering-portal seed (catalogue + the Riverside Stockfeeds test
# customer and its buyer user) runs in its own transaction so it always
# commits on a fresh Postgres deploy, independent of the workbook/costing
# seeds above. It is idempotent — safe on every boot.
seed_ordering_basics()
def seed_ordering_basics():
"""Seed the B2B ordering portal (catalogue + test customer). Idempotent."""
from app.seed_ordering import seed_ordering
with SessionLocal() as db:
ordering_report = seed_ordering(db)
if any(ordering_report.values()):
logger.info("Ordering portal seeded: %s", ordering_report)
db.commit()
def seed_all(): def seed_all():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
+12
View File
@@ -31,6 +31,9 @@ PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
("edit_mixes", "Create and edit mix master recipes"), ("edit_mixes", "Create and edit mix master recipes"),
("view_throughput", "View operations throughput"), ("view_throughput", "View operations throughput"),
("edit_throughput", "Create and edit operations throughput entries"), ("edit_throughput", "Create and edit operations throughput entries"),
("view_ordering", "View the B2B customer ordering portal and orders"),
("edit_ordering", "Manage catalogue, pricing, and process customer orders"),
("manage_ordering", "Full ordering administration: customers, pricing, order lifecycle, Xero"),
("view_scenarios", "View scenario planning"), ("view_scenarios", "View scenario planning"),
("edit_scenarios", "Create, run, approve, and reject scenarios"), ("edit_scenarios", "Create, run, approve, and reject scenarios"),
("manage_client_access", "Manage client accounts, users, feature access, and exports"), ("manage_client_access", "Manage client accounts, users, feature access, and exports"),
@@ -58,6 +61,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_mixes", "edit_mixes",
"view_throughput", "view_throughput",
"edit_throughput", "edit_throughput",
"view_ordering",
"edit_ordering",
"manage_ordering",
"view_scenarios", "view_scenarios",
"edit_scenarios", "edit_scenarios",
"manage_client_access", "manage_client_access",
@@ -93,6 +99,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_mixes", "edit_mixes",
"view_throughput", "view_throughput",
"edit_throughput", "edit_throughput",
"view_ordering",
"edit_ordering",
"manage_ordering",
], ],
}, },
"lean": { "lean": {
@@ -110,6 +119,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_mixes", "edit_mixes",
"view_throughput", "view_throughput",
"edit_throughput", "edit_throughput",
"view_ordering",
"edit_ordering",
"manage_ordering",
"view_scenarios", "view_scenarios",
"edit_scenarios", "edit_scenarios",
"manage_client_access", "manage_client_access",
+172
View File
@@ -0,0 +1,172 @@
"""Idempotent seed data for the B2B ordering portal.
Creates a small demo catalogue, a demo ordering customer + buyer user, a price
list, and a customer-specific price so the acceptance-criteria flow works
out of the box. Safe to run on every startup — it no-ops once seeded.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
from app.models.ordering import (
CatalogueProduct,
CustomerPriceAssignment,
CustomerProductPrice,
NotificationSetting,
PriceList,
PriceListItem,
ProductCategory,
)
from app.services.client_access_service import (
MODULE_INDEX,
ensure_user_module_permissions,
)
logger = logging.getLogger("data_entry_app.seed")
ORDERING_TENANT = settings.client_tenant_id
_CATEGORIES = [
("grains", "Grains", 10),
("premixed", "Premixed Products", 20),
("bags", "Bags", 30),
("bulk_loads", "Bulk Loads", 40),
("custom_blends", "Custom Blends", 50),
("services", "Services & Delivery", 60),
]
_PRODUCTS = [
# (name, sku, category, uom, unit_size, moq, base_price, stock, requires_quote)
("Cracked Maize", "GRN-MAIZE-20", "grains", "20kg bag", "20kg", 1, 24.50, "in_stock", False),
("Whole Barley", "GRN-BARLEY-20", "grains", "20kg bag", "20kg", 1, 21.00, "in_stock", False),
("Layer Premix", "PMX-LAYER-20", "premixed", "20kg bag", "20kg", 1, 32.75, "in_stock", False),
("Calf Starter Premix", "PMX-CALF-20", "premixed", "20kg bag", "20kg", 5, 38.40, "low_stock", False),
("Bulka Bag (1T)", "BAG-BULKA-1T", "bags", "each", "1 tonne", 1, 9.50, "in_stock", False),
("Bulk Maize Load", "BLK-MAIZE-T", "bulk_loads", "tonne", "per tonne", 1, 685.00, "made_to_order", False),
("Custom Horse Blend", "CST-HORSE-20", "custom_blends", "20kg bag", "20kg", 10, None, "made_to_order", True),
("Delivery (per pallet)", "SVC-DELIVERY", "services", "pallet", "1 pallet", 1, 45.00, "in_stock", False),
]
def seed_ordering(db: Session) -> dict[str, int]:
created = {"categories": 0, "products": 0, "customer": 0, "pricing": 0}
# Categories
existing_categories = {
c.slug for c in db.scalars(select(ProductCategory).where(ProductCategory.tenant_id == ORDERING_TENANT)).all()
}
for slug, name, sort_order in _CATEGORIES:
if slug in existing_categories:
continue
db.add(ProductCategory(tenant_id=ORDERING_TENANT, slug=slug, name=name, sort_order=sort_order))
created["categories"] += 1
# Catalogue products
existing_skus = {
p.sku for p in db.scalars(select(CatalogueProduct).where(CatalogueProduct.tenant_id == ORDERING_TENANT)).all()
}
product_by_sku: dict[str, CatalogueProduct] = {}
for name, sku, category, uom, unit_size, moq, base_price, stock, requires_quote in _PRODUCTS:
if sku in existing_skus:
continue
product = CatalogueProduct(
tenant_id=ORDERING_TENANT,
name=name,
sku=sku,
category=category,
unit_of_measure=uom,
unit_size=unit_size,
min_order_quantity=moq,
base_price=base_price,
stock_status=stock,
requires_quote=requires_quote,
)
db.add(product)
product_by_sku[sku] = product
created["products"] += 1
db.flush()
# Notification settings row
if db.scalar(select(NotificationSetting).where(NotificationSetting.tenant_id == ORDERING_TENANT)) is None:
db.add(
NotificationSetting(
tenant_id=ORDERING_TENANT,
internal_recipients=settings.admin_email,
send_customer_confirmation=True,
require_po_number=False,
from_email=settings.admin_email,
)
)
# Demo ordering customer + buyer user
demo = db.scalar(select(ClientAccount).where(ClientAccount.client_code == "RIVERSIDE"))
if demo is None:
demo = ClientAccount(
tenant_id="riverside-stockfeeds",
name="Riverside Stockfeeds",
client_code="RIVERSIDE",
status="active",
notes="Demo B2B ordering customer",
)
db.add(demo)
db.flush()
created["customer"] += 1
info = MODULE_INDEX["ordering"]
db.add(
ClientFeatureAccess(
tenant_id=demo.tenant_id,
client_account_id=demo.id,
feature_key="ordering",
feature_name=info["module_name"],
feature_group=info["module_group"],
description=info["description"],
enabled=True,
)
)
buyer = ClientUser(
tenant_id=demo.tenant_id,
client_account_id=demo.id,
full_name="Riverside Buyer",
email="buyer@riverside.example",
role="buyer",
status="active",
is_new_user=False,
)
db.add(buyer)
db.flush()
ensure_user_module_permissions(db, buyer)
# A customer-specific contract price + a small discount on the rest.
maize = db.scalar(
select(CatalogueProduct).where(
CatalogueProduct.tenant_id == ORDERING_TENANT, CatalogueProduct.sku == "GRN-MAIZE-20"
)
)
if maize is not None:
db.add(
CustomerProductPrice(
tenant_id=ORDERING_TENANT,
client_account_id=demo.id,
product_id=maize.id,
unit_price=22.00,
rule_type="contract",
contract_reference="2026 supply agreement",
)
)
db.add(
CustomerPriceAssignment(
tenant_id=ORDERING_TENANT,
client_account_id=demo.id,
price_list_id=None,
discount_percent=5.0,
)
)
created["pricing"] += 1
return created
+15 -4
View File
@@ -21,6 +21,7 @@ MODULE_CATALOG = (
("products", "Products", "pricing", "Review finished product pricing"), ("products", "Products", "pricing", "Review finished product pricing"),
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"), ("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
("operations_throughput", "Operations Throughput", "production", "Log production throughput and QA checks for grain/feed packing"), ("operations_throughput", "Operations Throughput", "production", "Log production throughput and QA checks for grain/feed packing"),
("ordering", "Ordering Portal", "commerce", "Browse the private catalogue, build and submit B2B orders"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"), ("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"), ("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"),
) )
@@ -79,15 +80,25 @@ def has_access_level(access_level: str | None, minimum_level: str) -> bool:
def default_access_level_for_role(role: str, module_key: str) -> str: def default_access_level_for_role(role: str, module_key: str) -> str:
normalized = role.strip().lower() normalized = role.strip().lower()
if normalized == "superadmin": if normalized == "superadmin":
return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput"} else "edit" return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput", "ordering"} else "edit"
if normalized == "admin": if normalized == "admin":
if module_key in {"mix_calculator", "operations_throughput"}: if module_key in {"mix_calculator", "operations_throughput", "ordering"}:
return "manage" return "manage"
return "edit" if module_key != "client_access" else "none" return "edit" if module_key != "client_access" else "none"
if normalized == "operator": if normalized == "operator":
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput"} else "none" return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput", "ordering"} else "none"
if normalized == "viewer": if normalized == "viewer":
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput"} else "none" return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput", "ordering"} else "none"
# --- B2B ordering-portal customer roles ---------------------------------
# Ordering customers are scoped to the ordering module (plus a dashboard
# view). They never see costing/admin modules.
if normalized == "owner":
return "manage" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
if normalized == "buyer":
return "edit" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
if normalized == "accounts":
# Accounts users review orders/invoicing but don't place orders.
return "view" if module_key in {"ordering", "dashboard"} else "none"
return "none" return "none"
+119
View File
@@ -0,0 +1,119 @@
"""Order notification service (stub interface).
Emits the two notifications the ordering spec requires when an order is
submitted:
* a confirmation to the customer, and
* an internal notification to configured recipients.
Real email delivery is intentionally **stubbed** behind this interface: no SMTP
provider is configured in dev/alpha, so notifications are logged and returned as
structured results. To go live, implement :func:`_deliver_email` (TODO) — no
caller changes needed.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.client_access import ClientAccount, ClientUser
from app.models.ordering import NotificationSetting, Order
logger = logging.getLogger("data_entry_app.ordering")
@dataclass
class NotificationResult:
channel: str # "customer" | "internal"
recipients: list[str]
subject: str
delivered: bool
detail: str
def get_or_create_settings(db: Session, tenant_id: str) -> NotificationSetting:
settings = db.scalar(
select(NotificationSetting).where(NotificationSetting.tenant_id == tenant_id)
)
if settings is None:
settings = NotificationSetting(tenant_id=tenant_id)
db.add(settings)
db.flush()
return settings
def _internal_recipients(settings: NotificationSetting) -> list[str]:
if not settings.internal_recipients:
return []
return [part.strip() for part in settings.internal_recipients.split(",") if part.strip()]
def _customer_recipients(db: Session, order: Order) -> list[str]:
users = db.scalars(
select(ClientUser).where(
ClientUser.client_account_id == order.client_account_id,
ClientUser.status == "active",
)
).all()
return [user.email for user in users if user.email]
def _deliver_email(*, to: list[str], subject: str, body: str, from_email: str | None) -> bool:
"""Deliver an email. Stubbed — logs instead of sending.
TODO (go-live): integrate the chosen provider (SMTP / SendGrid / SES) using
credentials from environment variables. Return True only on confirmed
delivery.
"""
logger.info("ordering.email.stub to=%s subject=%s", to, subject)
return False # stub: nothing actually sent
def send_order_submitted_notifications(db: Session, order: Order) -> list[NotificationResult]:
"""Send customer confirmation + internal notification for a submitted order."""
settings = get_or_create_settings(db, order.tenant_id)
customer = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
customer_name = customer.name if customer else "Customer"
order_ref = order.order_number or f"#{order.id}"
results: list[NotificationResult] = []
if settings.send_customer_confirmation:
to = _customer_recipients(db, order)
subject = f"Order {order_ref} received"
body = (
f"Thank you — we have received order {order_ref} for {customer_name}.\n"
f"Lines: {len(order.lines)}. Subtotal (ex GST): {order.subtotal_ex_gst:.2f}.\n"
"Our team will review and confirm shortly."
)
delivered = _deliver_email(to=to, subject=subject, body=body, from_email=settings.from_email) if to else False
results.append(
NotificationResult(
channel="customer",
recipients=to,
subject=subject,
delivered=delivered,
detail="stubbed" if not delivered else "sent",
)
)
internal = _internal_recipients(settings)
subject = f"New order {order_ref} from {customer_name}"
body = (
f"New order {order_ref} submitted by {order.created_by_name or 'a customer user'}.\n"
f"Customer: {customer_name}. Lines: {len(order.lines)}. "
f"Subtotal (ex GST): {order.subtotal_ex_gst:.2f}."
)
delivered = _deliver_email(to=internal, subject=subject, body=body, from_email=settings.from_email) if internal else False
results.append(
NotificationResult(
channel="internal",
recipients=internal,
subject=subject,
delivered=delivered,
detail="no recipients configured" if not internal else ("stubbed" if not delivered else "sent"),
)
)
return results
+112
View File
@@ -0,0 +1,112 @@
"""Order confirmation PDF generation (reportlab)."""
from __future__ import annotations
from datetime import datetime
from io import BytesIO
from app.models.client_access import ClientAccount
from app.models.ordering import Order
class OrderPdfUnavailableError(RuntimeError):
pass
def _fmt_money(value: float | None) -> str:
return "Quote" if value is None else f"${value:,.2f}"
def build_order_confirmation_pdf(order: Order, customer: ClientAccount | None) -> bytes:
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
except ModuleNotFoundError as exc: # pragma: no cover
raise OrderPdfUnavailableError(
"PDF generation is unavailable because 'reportlab' is not installed."
) from exc
buffer = BytesIO()
page_width, page_height = A4
pdf = canvas.Canvas(buffer, pagesize=A4)
margin = 40
y = page_height - margin
pdf.setFont("Helvetica-Bold", 18)
pdf.drawString(margin, y, "Order Confirmation")
y -= 24
pdf.setFont("Helvetica", 10)
order_ref = order.order_number or f"#{order.id}"
pdf.drawString(margin, y, f"Order: {order_ref}")
pdf.drawRightString(page_width - margin, y, datetime.utcnow().strftime("%d %b %Y %H:%M UTC"))
y -= 14
pdf.drawString(margin, y, f"Customer: {customer.name if customer else order.client_account_id}")
y -= 14
pdf.drawString(margin, y, f"Status: {order.status.replace('_', ' ').title()}")
y -= 14
if order.purchase_order_number:
pdf.drawString(margin, y, f"PO Number: {order.purchase_order_number}")
y -= 14
pdf.drawString(margin, y, f"Fulfilment: {order.fulfilment_method.title()}")
y -= 14
if order.requested_delivery_date:
pdf.drawString(margin, y, f"Requested date: {order.requested_delivery_date.strftime('%d %b %Y')}")
y -= 14
y -= 6
# Table header
pdf.setFont("Helvetica-Bold", 9)
pdf.setFillColor(colors.HexColor("#22362d"))
pdf.drawString(margin, y, "Product")
pdf.drawString(margin + 220, y, "SKU")
pdf.drawRightString(margin + 330, y, "Qty")
pdf.drawRightString(margin + 420, y, "Unit (ex GST)")
pdf.drawRightString(page_width - margin, y, "Line total")
y -= 6
pdf.setStrokeColor(colors.HexColor("#cccccc"))
pdf.line(margin, y, page_width - margin, y)
y -= 14
pdf.setFont("Helvetica", 9)
pdf.setFillColor(colors.black)
for line in order.lines:
if y < margin + 60:
pdf.showPage()
y = page_height - margin
pdf.setFont("Helvetica", 9)
unit = line.admin_override_price if line.admin_override_price is not None else line.unit_price
line_total = None if unit is None else round(unit * line.quantity, 2)
pdf.drawString(margin, y, (line.product_name or "")[:38])
pdf.drawString(margin + 220, y, (line.product_sku or "")[:16])
pdf.drawRightString(margin + 330, y, f"{line.quantity:g}")
pdf.drawRightString(margin + 420, y, _fmt_money(unit))
pdf.drawRightString(page_width - margin, y, _fmt_money(line_total))
y -= 14
y -= 4
pdf.line(margin, y, page_width - margin, y)
y -= 16
pdf.setFont("Helvetica-Bold", 10)
pdf.drawRightString(margin + 420, y, "Subtotal (ex GST)")
pdf.drawRightString(page_width - margin, y, _fmt_money(order.subtotal_ex_gst))
y -= 20
if order.requires_quote:
pdf.setFont("Helvetica-Oblique", 9)
pdf.setFillColor(colors.HexColor("#8a5a00"))
pdf.drawString(margin, y, "This order contains quote-only items. Final pricing will be confirmed by our team.")
y -= 14
if order.delivery_notes:
pdf.setFont("Helvetica", 9)
pdf.setFillColor(colors.black)
pdf.drawString(margin, y, f"Notes: {order.delivery_notes[:90]}")
pdf.setFont("Helvetica-Oblique", 8)
pdf.setFillColor(colors.HexColor("#888888"))
pdf.drawString(margin, margin - 12, "Prices exclude GST. This is an order confirmation, not a tax invoice.")
pdf.showPage()
pdf.save()
return buffer.getvalue()
+201
View File
@@ -0,0 +1,201 @@
"""Customer-specific pricing engine for the B2B ordering portal.
The backend is the single source of truth for prices — the frontend never
computes a final price. :func:`resolve_price` returns a fully-attributed
:class:`PriceResolution` so every order line, and any future report, can explain
exactly which rule produced the number.
Resolution priority (highest wins):
1. **Quote-only** — the product requires a manual quote, or the customer has a
``quote`` price rule for it. No automatic price.
2. **Fixed / contract** — a :class:`CustomerProductPrice` for this customer +
product (quantity tiers may refine it).
3. **Price list** — a :class:`PriceListItem` from the customer's assigned price
list (quantity tiers may refine it).
4. **Base + discount** — the catalogue list price, optionally reduced by the
customer's default discount percentage.
5. **Quote fallback** — no resolvable price ⇒ treated as quote-only.
All prices are GST-exclusive.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.ordering import (
CatalogueProduct,
CustomerPriceAssignment,
CustomerProductPrice,
PriceListItem,
PriceTier,
)
@dataclass(frozen=True)
class PriceResolution:
unit_price: float | None
price_source: str # one of ordering.PRICE_SOURCES
price_rule_id: int | None
discount_percent: float
requires_quote: bool
label: str
def line_total(self, quantity: float) -> float | None:
if self.unit_price is None:
return None
return round(self.unit_price * quantity, 4)
_SOURCE_LABELS = {
"fixed": "Fixed price",
"contract": "Contract price",
"price_list": "Price list",
"tiered": "Tiered price",
"base": "List price",
"quote": "Quote required",
}
def _best_tier_price(
db: Session,
*,
customer_product_price_id: int | None = None,
price_list_item_id: int | None = None,
quantity: float,
) -> float | None:
"""Return the unit price of the highest qualifying quantity tier, if any."""
stmt = select(PriceTier).where(PriceTier.min_quantity <= quantity)
if customer_product_price_id is not None:
stmt = stmt.where(PriceTier.customer_product_price_id == customer_product_price_id)
elif price_list_item_id is not None:
stmt = stmt.where(PriceTier.price_list_item_id == price_list_item_id)
else:
return None
tiers = db.scalars(stmt.order_by(PriceTier.min_quantity.desc())).all()
return tiers[0].unit_price if tiers else None
def _quote(label: str = "Quote required") -> PriceResolution:
return PriceResolution(
unit_price=None,
price_source="quote",
price_rule_id=None,
discount_percent=0.0,
requires_quote=True,
label=label,
)
def get_customer_assignment(
db: Session, *, client_account_id: int
) -> CustomerPriceAssignment | None:
return db.scalar(
select(CustomerPriceAssignment).where(
CustomerPriceAssignment.client_account_id == client_account_id
)
)
def resolve_price(
db: Session,
*,
client_account_id: int,
product: CatalogueProduct,
quantity: float,
) -> PriceResolution:
"""Resolve the GST-exclusive unit price for a customer + product + quantity."""
quantity = max(quantity, 0.0)
# 1. Product-level manual-quote flag.
if product.requires_quote:
return _quote()
# 2. Customer-specific fixed/contract/quote rule.
cpp = db.scalar(
select(CustomerProductPrice).where(
CustomerProductPrice.client_account_id == client_account_id,
CustomerProductPrice.product_id == product.id,
CustomerProductPrice.active.is_(True),
)
)
if cpp is not None:
if cpp.rule_type == "quote":
return _quote()
tier_price = _best_tier_price(
db, customer_product_price_id=cpp.id, quantity=quantity
)
if tier_price is not None:
return PriceResolution(
unit_price=round(tier_price, 4),
price_source="tiered",
price_rule_id=cpp.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS["tiered"],
)
if cpp.unit_price is not None:
source = "contract" if cpp.rule_type == "contract" else "fixed"
return PriceResolution(
unit_price=round(cpp.unit_price, 4),
price_source=source,
price_rule_id=cpp.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS[source],
)
assignment = get_customer_assignment(db, client_account_id=client_account_id)
# 3. Assigned price list.
if assignment is not None and assignment.price_list_id is not None:
pli = db.scalar(
select(PriceListItem).where(
PriceListItem.price_list_id == assignment.price_list_id,
PriceListItem.product_id == product.id,
)
)
if pli is not None:
tier_price = _best_tier_price(
db, price_list_item_id=pli.id, quantity=quantity
)
if tier_price is not None:
return PriceResolution(
unit_price=round(tier_price, 4),
price_source="tiered",
price_rule_id=pli.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS["tiered"],
)
return PriceResolution(
unit_price=round(pli.unit_price, 4),
price_source="price_list",
price_rule_id=pli.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS["price_list"],
)
# 4. Base price (+ optional customer discount).
if product.base_price is not None:
discount = assignment.discount_percent if assignment else 0.0
discount = min(max(discount, 0.0), 100.0)
unit_price = round(product.base_price * (1.0 - discount / 100.0), 4)
label = _SOURCE_LABELS["base"]
if discount:
label = f"List price less {discount:g}%"
return PriceResolution(
unit_price=unit_price,
price_source="base",
price_rule_id=product.id,
discount_percent=discount,
requires_quote=False,
label=label,
)
# 5. No resolvable price ⇒ quote.
return _quote("No price set — quote required")
+313
View File
@@ -0,0 +1,313 @@
"""Shared ordering-portal business logic: lifecycle, visibility, totals,
serialization, and audit.
Customer isolation is enforced by callers passing ``tenant_id`` /
``client_account_id`` into every query; this module never widens that scope.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession
from app.core.config import settings
from app.models.client_access import ClientAccount
from app.models.ordering import (
CatalogueProduct,
CustomerProductVisibility,
Order,
OrderLine,
OrderStatusHistory,
)
from app.services.client_access_service import record_audit_event
from app.services.ordering_pricing import resolve_price
# The ordering catalogue is "standard globally" — it lives under a single
# seller/ordering tenant, while individual customers (ClientAccounts) may have
# their own tenant_id. Customer isolation therefore keys on the globally-unique
# ``client_account_id``, NOT on the customer's session tenant. Admin and
# customer code both store/read ordering data under ORDERING_TENANT.
ORDERING_TENANT = settings.client_tenant_id
# --- Lifecycle ---------------------------------------------------------------
# Full internal lifecycle → the simplified status a customer sees.
CUSTOMER_VISIBLE_STATUS = {
"draft": "draft",
"submitted": "submitted",
"under_review": "processing",
"confirmed": "confirmed",
"sent_to_xero": "confirmed",
"in_production": "processing",
"ready_for_pickup": "ready",
"dispatched": "dispatched",
"completed": "completed",
"cancelled": "cancelled",
}
# Statuses an admin may move an order into from a given status. Cancellation is
# allowed from any non-terminal status (handled separately).
ALLOWED_ADMIN_TRANSITIONS: dict[str, set[str]] = {
"draft": {"submitted", "cancelled"},
"submitted": {"under_review", "confirmed", "cancelled"},
"under_review": {"confirmed", "submitted", "cancelled"},
"confirmed": {"sent_to_xero", "in_production", "cancelled"},
"sent_to_xero": {"in_production", "cancelled"},
"in_production": {"ready_for_pickup", "dispatched", "cancelled"},
"ready_for_pickup": {"dispatched", "completed", "cancelled"},
"dispatched": {"completed", "cancelled"},
"completed": set(),
"cancelled": set(),
}
# Customers may only edit an order while it is a draft.
CUSTOMER_EDITABLE_STATUSES = {"draft"}
def can_admin_transition(from_status: str, to_status: str) -> bool:
if to_status == "cancelled" and from_status not in {"completed", "cancelled"}:
return True
return to_status in ALLOWED_ADMIN_TRANSITIONS.get(from_status, set())
def record_status_change(
db: Session,
order: Order,
*,
to_status: str,
actor_type: str,
actor_name: str | None,
note: str | None = None,
) -> None:
db.add(
OrderStatusHistory(
tenant_id=order.tenant_id,
order_id=order.id,
from_status=order.status,
to_status=to_status,
actor_type=actor_type,
actor_name=actor_name,
note=note,
)
)
order.status = to_status
def next_order_number(db: Session, tenant_id: str) -> str:
# Cheap monotonic-ish numbering based on max id; good enough for v1.
last_id = db.scalar(select(Order.id).order_by(Order.id.desc()).limit(1)) or 0
return f"ORD-{last_id + 1:06d}"
# --- Product visibility ------------------------------------------------------
def visible_product_ids_for_customer(db: Session, *, tenant_id: str, client_account_id: int) -> set[int]:
"""Ids the customer is explicitly *hidden* from (opt-out model)."""
rows = db.scalars(
select(CustomerProductVisibility.product_id).where(
CustomerProductVisibility.client_account_id == client_account_id,
CustomerProductVisibility.tenant_id == tenant_id,
CustomerProductVisibility.visible.is_(False),
)
).all()
return set(rows)
def list_visible_products(
db: Session, *, tenant_id: str, client_account_id: int
) -> list[CatalogueProduct]:
hidden = visible_product_ids_for_customer(
db, tenant_id=tenant_id, client_account_id=client_account_id
)
products = db.scalars(
select(CatalogueProduct)
.where(CatalogueProduct.tenant_id == tenant_id, CatalogueProduct.active.is_(True))
.order_by(CatalogueProduct.category, CatalogueProduct.name)
).all()
return [p for p in products if p.id not in hidden]
# --- Totals ------------------------------------------------------------------
def effective_unit_price(line: OrderLine) -> float | None:
if line.admin_override_price is not None:
return line.admin_override_price
return line.unit_price
def recompute_order_totals(order: Order) -> None:
subtotal = 0.0
requires_quote = False
for line in order.lines:
unit = effective_unit_price(line)
if line.requires_quote or unit is None:
requires_quote = True
line.line_total = None
continue
line.line_total = round(unit * line.quantity, 4)
subtotal += line.line_total
order.subtotal_ex_gst = round(subtotal, 4)
order.requires_quote = requires_quote
# --- Serialization -----------------------------------------------------------
def serialize_product(
product: CatalogueProduct,
*,
db: Session | None = None,
client_account_id: int | None = None,
quantity: float = 1.0,
) -> dict:
"""Serialize a catalogue product. When ``db`` + ``client_account_id`` are
given, attach the resolved customer-specific price + provenance."""
data = {
"id": product.id,
"name": product.name,
"sku": product.sku,
"description": product.description,
"category": product.category,
"image_url": product.image_url,
"unit_size": product.unit_size,
"unit_of_measure": product.unit_of_measure,
"min_order_quantity": product.min_order_quantity,
"stock_status": product.stock_status,
"active": product.active,
"requires_quote": product.requires_quote,
"base_price": product.base_price,
"created_at": product.created_at,
}
if db is not None and client_account_id is not None:
resolution = resolve_price(
db, client_account_id=client_account_id, product=product, quantity=quantity
)
data["price"] = {
"unit_price": resolution.unit_price,
"price_source": resolution.price_source,
"price_rule_id": resolution.price_rule_id,
"discount_percent": resolution.discount_percent,
"requires_quote": resolution.requires_quote,
"label": resolution.label,
}
return data
def serialize_order_line(line: OrderLine, *, for_admin: bool) -> dict:
data = {
"id": line.id,
"product_id": line.product_id,
"product_name": line.product_name,
"product_sku": line.product_sku,
"quantity": line.quantity,
"unit_price": effective_unit_price(line),
"line_total": line.line_total,
"requires_quote": line.requires_quote,
"price_source": line.price_source,
"discount_percent": line.discount_percent,
"notes": line.notes,
}
if for_admin:
data.update(
{
"resolved_unit_price": line.unit_price,
"admin_override_price": line.admin_override_price,
"admin_override_reason": line.admin_override_reason,
"price_rule_id": line.price_rule_id,
}
)
return data
def serialize_order(order: Order, *, for_admin: bool) -> dict:
status = order.status if for_admin else CUSTOMER_VISIBLE_STATUS.get(order.status, order.status)
data = {
"id": order.id,
"order_number": order.order_number,
"status": status,
"client_account_id": order.client_account_id,
"created_by_name": order.created_by_name,
"purchase_order_number": order.purchase_order_number,
"delivery_notes": order.delivery_notes,
"requested_delivery_date": order.requested_delivery_date,
"fulfilment_method": order.fulfilment_method,
"subtotal_ex_gst": order.subtotal_ex_gst,
"requires_quote": order.requires_quote,
"submitted_at": order.submitted_at,
"created_at": order.created_at,
"updated_at": order.updated_at,
"editable": order.status in CUSTOMER_EDITABLE_STATUSES,
"lines": [serialize_order_line(line, for_admin=for_admin) for line in order.lines],
}
if for_admin:
data.update(
{
"raw_status": order.status,
"admin_notes": order.admin_notes,
"reopened": order.reopened,
"xero_status": order.xero_status,
"xero_invoice_id": order.xero_invoice_id,
"status_history": [
{
"id": h.id,
"from_status": h.from_status,
"to_status": h.to_status,
"actor_type": h.actor_type,
"actor_name": h.actor_name,
"note": h.note,
"created_at": h.created_at,
}
for h in order.status_history
],
}
)
return data
# --- Audit -------------------------------------------------------------------
def audit_order_event(
db: Session,
*,
session: AuthSession,
order: Order,
action: str,
summary: str,
target_id: int | None = None,
) -> None:
"""Record an ordering audit event onto the shared audit trail."""
actor_type = "lean_admin" if session.role in {"admin", "internal"} else "customer"
record_audit_event(
db,
tenant_id=order.tenant_id,
client_account_id=order.client_account_id,
actor_type=actor_type,
actor_name=session.name or "",
actor_email=session.email or "",
actor_role=session.client_role or session.role,
action=action,
target_type="order",
target_id=target_id if target_id is not None else order.id,
module_key="ordering",
summary=summary,
)
def get_customer_account(db: Session, *, client_account_id: int) -> ClientAccount | None:
# client_account_id is a global primary key; a customer's own tenant_id may
# differ from ORDERING_TENANT, so we look up by id alone.
return db.scalar(select(ClientAccount).where(ClientAccount.id == client_account_id))
def ensure_customer_active(account: ClientAccount | None) -> None:
from fastapi import HTTPException, status
if account is None:
raise HTTPException(status_code=404, detail="Customer account not found")
if account.status != "active":
raise HTTPException(status_code=403, detail="This customer account is disabled")
+181
View File
@@ -0,0 +1,181 @@
"""Xero integration layer for the ordering portal.
This is a **clean, stubbed interface**. No Xero credentials are hard-coded — all
configuration comes from environment variables (``XERO_*``). When credentials
are absent (the default in dev/alpha), the service runs in "stub" mode: it
records what *would* be sent and returns a deterministic fake invoice id so the
rest of the order lifecycle can be exercised end-to-end.
To go live, implement the real HTTP calls inside :func:`_submit_to_xero_api`
(see the TODOs) — no caller needs to change.
Service responsibilities (per spec):
* Map a customer (ClientAccount) to a Xero contact.
* Map catalogue products to Xero item codes.
* Create a draft invoice equivalent for a confirmed order.
* Record Xero response ids/status (via XeroSyncLog, written by the caller).
* Handle failures gracefully (never raise into the request path).
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from datetime import datetime
from app.models.client_access import ClientAccount
from app.models.ordering import Order
@dataclass
class XeroConfig:
client_id: str | None = None
client_secret: str | None = None
tenant_id: str | None = None
base_url: str = "https://api.xero.com/api.xro/2.0"
@property
def configured(self) -> bool:
return bool(self.client_id and self.client_secret and self.tenant_id)
@classmethod
def from_env(cls) -> "XeroConfig":
return cls(
client_id=os.getenv("XERO_CLIENT_ID") or None,
client_secret=os.getenv("XERO_CLIENT_SECRET") or None,
tenant_id=os.getenv("XERO_TENANT_ID") or None,
base_url=os.getenv("XERO_API_BASE_URL", "https://api.xero.com/api.xro/2.0"),
)
@dataclass
class XeroSubmissionResult:
status: str # "success" | "failed"
xero_invoice_id: str | None
message: str
request_summary: str
stubbed: bool = False
line_items: list[dict] = field(default_factory=list)
def map_customer_to_contact(customer: ClientAccount) -> dict:
"""Map a customer account onto a Xero contact payload."""
return {
# TODO: persist and reuse a real Xero ContactID once the contact has
# been created/matched in Xero. For now we key on the client code.
"ContactNumber": customer.client_code,
"Name": customer.name,
}
def map_product_to_item_code(product_sku: str) -> str:
"""Map a catalogue SKU onto a Xero item code.
TODO: support an explicit SKU→Xero item-code mapping table if the codes
diverge. Today the SKU is used directly.
"""
return product_sku
def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
"""Build the Xero draft-invoice payload for a confirmed order."""
line_items = []
for line in order.lines:
if line.requires_quote or line.unit_price is None:
# Quote-only lines can't carry a price; skip until quoted.
continue
unit_price = line.admin_override_price if line.admin_override_price is not None else line.unit_price
line_items.append(
{
"ItemCode": map_product_to_item_code(line.product_sku),
"Description": line.product_name,
"Quantity": line.quantity,
"UnitAmount": unit_price,
"AccountCode": os.getenv("XERO_SALES_ACCOUNT_CODE", "200"),
# Prices are GST-exclusive throughout the platform.
"TaxType": os.getenv("XERO_TAX_TYPE", "OUTPUT"),
}
)
return {
"Type": "ACCREC",
"Status": "DRAFT",
"Contact": map_customer_to_contact(customer),
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
"LineAmountTypes": "Exclusive",
"LineItems": line_items,
}
def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResult:
"""Real Xero submission. Stubbed until credentials/endpoints are wired.
TODO (go-live):
1. Obtain an OAuth2 token (client-credentials or stored refresh token).
2. POST ``payload`` to ``{config.base_url}/Invoices`` with the
``Xero-tenant-id`` header set to ``config.tenant_id``.
3. Parse ``Invoices[0].InvoiceID`` from the response.
4. Map non-2xx responses onto ``status="failed"`` with the error body.
"""
summary = f"{len(payload.get('LineItems', []))} line(s) for {payload['Contact']['Name']}"
# Real call would go here. Intentionally not implemented yet.
return XeroSubmissionResult(
status="failed",
xero_invoice_id=None,
message="Xero live submission is not implemented yet (stub interface).",
request_summary=summary,
stubbed=False,
line_items=payload.get("LineItems", []),
)
def submit_order_to_xero(order: Order, customer: ClientAccount) -> XeroSubmissionResult:
"""Submit a confirmed order to Xero, or stub it when unconfigured.
Never raises — failures are returned as ``status="failed"`` results so the
order lifecycle can record the attempt and continue.
"""
config = XeroConfig.from_env()
payload = build_invoice_payload(order, customer)
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
if not config.configured:
# Stub mode: deterministic fake invoice id so downstream flows work.
fake_id = f"STUB-INV-{order.id:06d}"
return XeroSubmissionResult(
status="success",
xero_invoice_id=fake_id,
message="Xero not configured — order recorded in stub mode.",
request_summary=summary,
stubbed=True,
line_items=payload["LineItems"],
)
try:
return _submit_to_xero_api(config, payload)
except Exception as exc: # pragma: no cover - defensive: never break the request path
return XeroSubmissionResult(
status="failed",
xero_invoice_id=None,
message=f"Xero submission error: {exc}",
request_summary=summary,
stubbed=False,
line_items=payload["LineItems"],
)
def xero_status_snapshot() -> dict:
config = XeroConfig.from_env()
return {
"configured": config.configured,
"mode": "live" if config.configured else "stub",
"base_url": config.base_url,
"checked_at": datetime.utcnow().isoformat(),
"missing_env": [
name
for name, value in (
("XERO_CLIENT_ID", config.client_id),
("XERO_CLIENT_SECRET", config.client_secret),
("XERO_TENANT_ID", config.tenant_id),
)
if not value
],
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "data-entry-app-backend" name = "data-entry-app-backend"
version = "0.1.12" version = "0.1.14"
description = "Costing platform MVP backend" description = "Costing platform MVP backend"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+249
View File
@@ -0,0 +1,249 @@
"""End-to-end ordering flow test exercising the acceptance criteria.
Drives the real FastAPI app via TestClient against an in-memory database, using
directly-issued auth tokens (bypassing the password-based login endpoints).
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.access import INTERNAL_USER_SUBJECT
from app.core.config import settings
from app.core.security import issue_token
from app.db.session import Base, get_db
from app.main import app
from app.models.access import User
from app.models.client_access import ClientUser
from app.seed_access import seed_access
from app.services import ordering_service as svc
ORDERING_TENANT = svc.ORDERING_TENANT
@pytest.fixture()
def client():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def override_get_db():
db = TestingSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
test_client._session_factory = TestingSession # type: ignore[attr-defined]
yield test_client
app.dependency_overrides.clear()
def _admin_headers() -> dict[str, str]:
token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
return {"Authorization": f"Bearer {token}"}
def _customer_headers(db_session_factory, *, account_id: int) -> dict[str, str]:
db = db_session_factory()
user = db.scalar(select(ClientUser).where(ClientUser.client_account_id == account_id))
payload = {
"name": user.full_name,
"email": user.email,
"role": "client",
"tenant_id": user.tenant_id,
"client_role": user.role,
"user_id": user.id,
"client_account_id": user.client_account_id,
}
db.close()
token = issue_token(payload)
return {"Authorization": f"Bearer {token}"}
def test_full_b2b_ordering_flow(client):
admin = _admin_headers()
# 1. Admin creates a customer.
r = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Acme Farms", "client_code": "ACME"})
assert r.status_code == 201, r.text
customer = r.json()
customer_id = customer["id"]
# 2. Admin creates a user for that customer.
r = client.post(
f"/api/ordering-admin/customers/{customer_id}/users",
headers=admin,
json={"full_name": "Buyer Bob", "email": "bob@acme.test", "role": "buyer"},
)
assert r.status_code == 201, r.text
# 3. Admin creates products.
r = client.post(
"/api/ordering-admin/products",
headers=admin,
json={"name": "Maize 1T", "sku": "MAIZE-1T", "category": "grains", "base_price": 100.0, "min_order_quantity": 1},
)
assert r.status_code == 201, r.text
product = r.json()
product_id = product["id"]
r = client.post(
"/api/ordering-admin/products",
headers=admin,
json={"name": "Hidden Blend", "sku": "HIDDEN-1", "category": "custom_blends", "base_price": 50.0},
)
hidden_product_id = r.json()["id"]
# 4. Admin assigns a customer-specific price (contract @ 92.5).
r = client.put(
f"/api/ordering-admin/customers/{customer_id}/product-prices",
headers=admin,
json={"product_id": product_id, "unit_price": 92.5, "rule_type": "contract"},
)
assert r.status_code == 200, r.text
# 4b. Admin hides the second product from this customer.
r = client.put(
f"/api/ordering-admin/customers/{customer_id}/visibility",
headers=admin,
json={"product_id": hidden_product_id, "visible": False},
)
assert r.status_code == 200, r.text
# 5. Customer logs in (token) and sees only their available product + price.
cust = _customer_headers(client._session_factory, account_id=customer_id)
r = client.get("/api/ordering/catalogue", headers=cust)
assert r.status_code == 200, r.text
catalogue = r.json()
skus = {p["sku"] for p in catalogue}
assert skus == {"MAIZE-1T"} # hidden product is not visible
maize = catalogue[0]
assert maize["price"]["unit_price"] == 92.5
assert maize["price"]["price_source"] == "contract"
# 6. Customer creates a draft order.
r = client.post(
"/api/ordering/orders",
headers=cust,
json={"lines": [{"product_id": product_id, "quantity": 3}], "fulfilment_method": "delivery"},
)
assert r.status_code == 201, r.text
draft = r.json()
assert draft["status"] == "draft"
assert draft["subtotal_ex_gst"] == 277.5 # 3 * 92.5
order_id = draft["id"]
# Minimum-order-quantity validation.
r = client.post(
"/api/ordering/orders",
headers=cust,
json={"lines": [{"product_id": product_id, "quantity": 0.5}]},
)
# quantity below MOQ of 1 → caught by schema (gt=0 ok) then MOQ check 422.
assert r.status_code == 422
# 7. Customer submits the order — price is frozen on the line.
r = client.post(f"/api/ordering/orders/{order_id}/submit", headers=cust, json={"purchase_order_number": "PO-77"})
assert r.status_code == 200, r.text
submitted = r.json()
assert submitted["status"] == "submitted"
assert submitted["order_number"].startswith("ORD-")
assert submitted["lines"][0]["unit_price"] == 92.5
assert submitted["purchase_order_number"] == "PO-77"
# Customer can no longer edit a submitted order.
r = client.patch(f"/api/ordering/orders/{order_id}", headers=cust, json={"delivery_notes": "late edit"})
assert r.status_code == 409
# 8. Admin sees the submitted order and manages it.
r = client.get("/api/ordering-admin/orders", headers=admin)
assert r.status_code == 200, r.text
admin_orders = r.json()
assert any(o["id"] == order_id for o in admin_orders)
assert admin_orders[0]["customer_name"] == "Acme Farms"
# Status progression with lifecycle enforcement.
r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "confirmed"})
assert r.status_code == 200, r.text
# Illegal jump is rejected.
r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "completed"})
assert r.status_code == 409
# 9. Xero submission (stub) succeeds and records an invoice id.
r = client.post(f"/api/ordering-admin/orders/{order_id}/send-to-xero", headers=admin)
assert r.status_code == 200, r.text
xero = r.json()
assert xero["xero_result"]["status"] == "success"
assert xero["xero_result"]["stubbed"] is True
assert xero["xero_invoice_id"].startswith("STUB-INV-")
assert xero["raw_status"] == "sent_to_xero"
def test_customer_isolation_between_companies(client):
admin = _admin_headers()
# Two customers, each with a user.
a = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Alpha", "client_code": "ALPHA"}).json()
b = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Beta", "client_code": "BETA"}).json()
client.post(f"/api/ordering-admin/customers/{a['id']}/users", headers=admin, json={"full_name": "A1", "email": "a1@alpha.test", "role": "owner"})
client.post(f"/api/ordering-admin/customers/{b['id']}/users", headers=admin, json={"full_name": "B1", "email": "b1@beta.test", "role": "owner"})
product = client.post("/api/ordering-admin/products", headers=admin, json={"name": "Barley 1T", "sku": "BAR-1T", "category": "grains", "base_price": 80.0}).json()
headers_a = _customer_headers(client._session_factory, account_id=a["id"])
headers_b = _customer_headers(client._session_factory, account_id=b["id"])
# Alpha creates and submits an order.
order = client.post("/api/ordering/orders", headers=headers_a, json={"lines": [{"product_id": product["id"], "quantity": 2}]}).json()
# Beta must not be able to read Alpha's order.
r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_b)
assert r.status_code == 404
# Alpha can.
r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_a)
assert r.status_code == 200
def test_internal_staff_can_manage_orders(client):
# Internal Hunter Stock Feeds staff (not the legacy /admin login) manage the
# ordering portal via the client/internal session + manage_ordering perm.
db = client._session_factory()
seed_access(db)
db.commit()
admin_user = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
user_id = admin_user.id
db.close()
token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user_id})
headers = {"Authorization": f"Bearer {token}"}
# Can list customers and create catalogue products through the admin API.
assert client.get("/api/ordering-admin/customers", headers=headers).status_code == 200
r = client.post(
"/api/ordering-admin/products",
headers=headers,
json={"name": "Staff Wheat", "sku": "WHEAT-STAFF", "category": "grains", "base_price": 30.0},
)
assert r.status_code == 201, r.text
def test_disabled_customer_cannot_order(client):
admin = _admin_headers()
cust = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Gamma", "client_code": "GAMMA"}).json()
client.post(f"/api/ordering-admin/customers/{cust['id']}/users", headers=admin, json={"full_name": "G1", "email": "g1@gamma.test", "role": "buyer"})
client.post("/api/ordering-admin/products", headers=admin, json={"name": "Oats 1T", "sku": "OATS-1T", "category": "grains", "base_price": 70.0})
# Disable the customer account.
r = client.patch(f"/api/ordering-admin/customers/{cust['id']}", headers=admin, json={"status": "disabled"})
assert r.status_code == 200
headers = _customer_headers(client._session_factory, account_id=cust["id"])
r = client.get("/api/ordering/catalogue", headers=headers)
assert r.status_code == 403
+156
View File
@@ -0,0 +1,156 @@
"""Unit tests for the backend-owned ordering pricing engine."""
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.session import Base
from app.models.ordering import (
CatalogueProduct,
CustomerPriceAssignment,
CustomerProductPrice,
PriceList,
PriceListItem,
PriceTier,
)
from app.services.ordering_pricing import resolve_price
CUSTOMER_ID = 1
TENANT = "test-tenant"
def _session() -> Session:
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _product(db: Session, **overrides) -> CatalogueProduct:
values = {
"tenant_id": TENANT,
"name": "Maize 1T",
"sku": "MAIZE-1T",
"category": "grains",
"base_price": 100.0,
"min_order_quantity": 1.0,
"requires_quote": False,
}
values.update(overrides)
product = CatalogueProduct(**values)
db.add(product)
db.flush()
return product
def test_base_price_with_no_rules():
db = _session()
product = _product(db)
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=2)
assert res.unit_price == 100.0
assert res.price_source == "base"
assert res.requires_quote is False
assert res.line_total(2) == 200.0
def test_customer_discount_applies_to_base():
db = _session()
product = _product(db)
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, discount_percent=10.0))
db.flush()
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
assert res.unit_price == 90.0
assert res.price_source == "base"
assert res.discount_percent == 10.0
def test_price_list_overrides_base():
db = _session()
product = _product(db)
pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
db.add(pl)
db.flush()
db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id, discount_percent=10.0))
db.flush()
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
# Price list wins over base+discount.
assert res.unit_price == 80.0
assert res.price_source == "price_list"
def test_customer_fixed_price_wins_over_price_list():
db = _session()
product = _product(db)
pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
db.add(pl)
db.flush()
db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id))
db.add(
CustomerProductPrice(
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=72.5, rule_type="contract"
)
)
db.flush()
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
assert res.unit_price == 72.5
assert res.price_source == "contract"
def test_quantity_tier_wins_when_threshold_met():
db = _session()
product = _product(db)
cpp = CustomerProductPrice(
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=90.0, rule_type="fixed"
)
db.add(cpp)
db.flush()
db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=10, unit_price=85.0))
db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=50, unit_price=80.0))
db.flush()
# Below first tier → flat fixed price.
assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=5).unit_price == 90.0
# Meets first tier.
mid = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=10)
assert mid.unit_price == 85.0
assert mid.price_source == "tiered"
# Meets highest tier.
assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=60).unit_price == 80.0
def test_product_requires_quote():
db = _session()
product = _product(db, requires_quote=True)
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
assert res.requires_quote is True
assert res.unit_price is None
assert res.price_source == "quote"
assert res.line_total(5) is None
def test_customer_quote_rule_forces_quote():
db = _session()
product = _product(db)
db.add(
CustomerProductPrice(
tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=None, rule_type="quote"
)
)
db.flush()
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
assert res.requires_quote is True
assert res.price_source == "quote"
def test_no_price_falls_back_to_quote():
db = _session()
product = _product(db, base_price=None)
res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
assert res.requires_quote is True
assert res.price_source == "quote"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.12", "version": "0.1.14",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+5 -6
View File
@@ -5,15 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.png" /> <link rel="icon" href="/favicon.png" />
<script> <script>
// Resolve the theme before first paint so there is no light-mode flash. // Resolve the theme before first paint so there is no dark-mode flash.
// Dark mode is strictly opt-in: it loads only when the user has explicitly
// chosen it. Absent preference (or the legacy 'system' value) stays light,
// so the OS scheme never pulls the app into dark on its own.
(function () { (function () {
try { try {
var pref = localStorage.getItem('theme'); var pref = localStorage.getItem('theme');
var dark = document.documentElement.dataset.theme = pref === 'dark' ? 'dark' : 'light';
pref === 'dark' ||
((!pref || pref === 'system') &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
} catch (e) { } catch (e) {
document.documentElement.dataset.theme = 'light'; document.documentElement.dataset.theme = 'light';
} }
+95 -1
View File
@@ -29,6 +29,15 @@ import type {
RawMaterial, RawMaterial,
RawMaterialCreateInput, RawMaterialCreateInput,
RawMaterialPriceCreateInput, RawMaterialPriceCreateInput,
CatalogueProduct,
CustomerPricing,
CustomerVisibilityRow,
DraftOrderInput,
Order,
OrderingCustomer,
OrderingCustomerUser,
OrderingNotificationSettings,
XeroStatus,
Scenario, Scenario,
ThroughputEntry, ThroughputEntry,
ThroughputEntryCreateInput, ThroughputEntryCreateInput,
@@ -486,5 +495,90 @@ export const api = {
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, { request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'manager') }, 'manager'),
// --- B2B ordering portal (customer) ---------------------------------------
ordering: {
catalogue: (params?: { category?: string; q?: string }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.category) search.set('category', params.category);
if (params?.q) search.set('q', params.q);
const qs = search.toString();
return cachedFetchJson<CatalogueProduct[]>(`/api/ordering/catalogue${qs ? `?${qs}` : ''}`, 'client', fetcher);
},
product: (productId: number, quantity = 1, fetcher?: ApiFetch) =>
request<CatalogueProduct>(`/api/ordering/catalogue/${productId}?quantity=${quantity}`, { method: 'GET' }, 'client', fetcher),
orders: (statusFilter?: string, fetcher?: ApiFetch) =>
cachedFetchJson<Order[]>(`/api/ordering/orders${statusFilter ? `?status=${statusFilter}` : ''}`, 'client', fetcher),
order: (orderId: number, fetcher?: ApiFetch) =>
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
createDraft: (payload: DraftOrderInput) =>
request<Order>('/api/ordering/orders', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateDraft: (orderId: number, payload: Partial<DraftOrderInput>) =>
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
deleteDraft: (orderId: number) =>
request<void>(`/api/ordering/orders/${orderId}`, { method: 'DELETE' }, 'client'),
submit: (orderId: number, payload: Partial<DraftOrderInput> = {}) =>
request<Order>(`/api/ordering/orders/${orderId}/submit`, { method: 'POST', body: JSON.stringify(payload) }, 'client'),
reorder: (orderId: number) =>
request<Order>(`/api/ordering/orders/${orderId}/reorder`, { method: 'POST' }, 'client'),
confirmationPdf: (orderId: number) =>
requestBlob(`/api/ordering/orders/${orderId}/confirmation.pdf`, {}, 'client')
},
// --- B2B ordering portal (admin) ------------------------------------------
orderingAdmin: {
customers: (fetcher?: ApiFetch) => cachedFetchJson<OrderingCustomer[]>('/api/ordering-admin/customers', 'client', fetcher),
createCustomer: (payload: { name: string; client_code: string; tenant_id?: string; notes?: string }) =>
request<OrderingCustomer>('/api/ordering-admin/customers', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateCustomer: (customerId: number, payload: { name?: string; status?: string; notes?: string }) =>
request<OrderingCustomer>(`/api/ordering-admin/customers/${customerId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
customerUsers: (customerId: number, fetcher?: ApiFetch) =>
cachedFetchJson<OrderingCustomerUser[]>(`/api/ordering-admin/customers/${customerId}/users`, 'client', fetcher),
createCustomerUser: (customerId: number, payload: { full_name: string; email: string; role: string }) =>
request<OrderingCustomerUser>(`/api/ordering-admin/customers/${customerId}/users`, { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateCustomerUser: (customerId: number, userId: number, payload: { full_name?: string; role?: string; status?: string }) =>
request<OrderingCustomerUser>(`/api/ordering-admin/customers/${customerId}/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
products: (fetcher?: ApiFetch) => cachedFetchJson<CatalogueProduct[]>('/api/ordering-admin/products', 'client', fetcher),
createProduct: (payload: Partial<CatalogueProduct>) =>
request<CatalogueProduct>('/api/ordering-admin/products', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
updateProduct: (productId: number, payload: Partial<CatalogueProduct>) =>
request<CatalogueProduct>(`/api/ordering-admin/products/${productId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
visibility: (customerId: number, fetcher?: ApiFetch) =>
cachedFetchJson<CustomerVisibilityRow[]>(`/api/ordering-admin/customers/${customerId}/visibility`, 'client', fetcher),
setVisibility: (customerId: number, payload: { product_id: number; visible: boolean }) =>
request(`/api/ordering-admin/customers/${customerId}/visibility`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
pricing: (customerId: number, fetcher?: ApiFetch) =>
cachedFetchJson<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/pricing`, 'client', fetcher),
setAssignment: (customerId: number, payload: { price_list_id: number | null; discount_percent: number }) =>
request<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/assignment`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
setProductPrice: (
customerId: number,
payload: { product_id: number; unit_price: number | null; rule_type: string; contract_reference?: string | null; notes?: string | null; active?: boolean }
) => request<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/product-prices`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
deleteProductPrice: (customerId: number, productId: number) =>
request<void>(`/api/ordering-admin/customers/${customerId}/product-prices/${productId}`, { method: 'DELETE' }, 'client'),
orders: (params?: { status?: string; customer_id?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.status) search.set('status', params.status);
if (params?.customer_id != null) search.set('customer_id', String(params.customer_id));
const qs = search.toString();
return cachedFetchJson<Order[]>(`/api/ordering-admin/orders${qs ? `?${qs}` : ''}`, 'client', fetcher);
},
order: (orderId: number, fetcher?: ApiFetch) =>
request<Order>(`/api/ordering-admin/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
updateStatus: (orderId: number, payload: { to_status: string; note?: string }) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/status`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
overrideLine: (orderId: number, lineId: number, payload: { quantity?: number; unit_price?: number; reason?: string }) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/lines/${lineId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
reopen: (orderId: number, note?: string) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/reopen`, { method: 'POST', body: JSON.stringify({ note }) }, 'client'),
sendToXero: (orderId: number) =>
request<Order>(`/api/ordering-admin/orders/${orderId}/send-to-xero`, { method: 'POST' }, 'client'),
notificationSettings: (fetcher?: ApiFetch) =>
cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher),
updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) =>
request<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher)
}
}; };
+48
View File
@@ -0,0 +1,48 @@
import packageInfo from '../../package.json';
/**
* Release notes shown in the "What's new" dialog. This is the single source of
* truth for the changelog: add a new entry at the top of `changelog` whenever
* the version in package.json is bumped, and the dialog will surface it once per
* user on their next login (see $lib/whats-new and WhatsNewDialog.svelte).
*/
export type ChangelogEntry = {
version: string;
/** ISO date (YYYY-MM-DD) the version shipped. */
date: string;
highlights: string[];
};
/** The running app version, read straight from package.json at build time. */
export const APP_VERSION: string = packageInfo.version;
export const changelog: ChangelogEntry[] = [
{
version: '0.1.14',
date: '2026-06-11',
highlights: [
'New: private B2B customer ordering portal — customers browse their catalogue, see account-specific pricing, and submit orders.',
'Order management console for internal staff: review orders, manage products, pricing, and the full order lifecycle.',
'Customer-specific pricing engine (fixed, contract, price lists, tiered, and quote-only) calculated on the backend.',
'Order confirmations (PDF) and Xero submission, behind a clean integration layer.'
]
},
{
version: '0.1.12',
date: '2026-06-10',
highlights: [
'Mix Calculator: Changed from selecting Product to Mix.',
'Web app design improved',
'Throughput tab ready for testing',
'Costing Editor tab ready for testing'
]
}
];
/** The changelog entry matching a specific version, if one exists. */
export function changelogFor(version: string): ChangelogEntry | undefined {
return changelog.find((entry) => entry.version === version);
}
/** The entry for the version the app is currently running, if documented. */
export const currentChangelog: ChangelogEntry | undefined = changelogFor(APP_VERSION);
@@ -1,391 +0,0 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { api } from '$lib/api';
import { adminSession, sessionHydrated } from '$lib/session';
const navigation = [
{ href: '/admin', label: 'Overview', shortLabel: 'OV' },
{ href: '/admin/client-access', label: 'Client Access', shortLabel: 'CA' }
];
let { children } = $props();
let isRestoringSession = $state(false);
let restoredSessionKey = $state<string | null>(null);
function matchesRoute(href: string, pathname: string) {
return href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
}
function pageTitle(pathname: string) {
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Overview';
}
function initials(name: string) {
return name
.split(' ')
.map((piece) => piece[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
async function signOut() {
try {
await api.adminLogout();
} catch {
// Clearing the local session remains the safe fallback.
} finally {
adminSession.clear();
}
}
const isProtectedRoute = $derived(page.url.pathname !== '/admin');
$effect(() => {
const hydrated = $sessionHydrated;
const sessionKey = $adminSession ? `${$adminSession.role}:${$adminSession.email}` : null;
if (!hydrated) {
return;
}
if (!sessionKey) {
isRestoringSession = false;
restoredSessionKey = null;
return;
}
if (restoredSessionKey === sessionKey) {
return;
}
restoredSessionKey = sessionKey;
isRestoringSession = true;
invalidateAll().finally(() => {
if (restoredSessionKey === sessionKey) {
isRestoringSession = false;
}
});
});
</script>
<svelte:head>
<title>{pageTitle(page.url.pathname)} | Lean 101 Admin Panel</title>
</svelte:head>
<div class="admin-shell">
<aside class="admin-sidebar">
<a class="admin-brand" href="/admin">
<span class="brand-mark">L1</span>
<span>Lean 101 Admin Panel</span>
</a>
<p class="admin-copy">
Internal workspace for Lean 101 operators managing client access and controlled workspace changes.
</p>
<nav class="admin-nav" aria-label="Admin navigation">
{#each navigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="admin-footer">
<a href="/">Open client workspace</a>
{#if $adminSession}
<button type="button" onclick={signOut}>Sign out</button>
{/if}
</div>
</aside>
<div class="admin-main">
<header class="admin-topbar">
<div>
<p class="eyebrow">Admin Area</p>
<h1>{pageTitle(page.url.pathname)}</h1>
</div>
{#if !$sessionHydrated}
<div class="profile-card guest">
<span class="profile-avatar">A</span>
<div>
<strong>Checking saved session</strong>
<span>Restoring admin access</span>
</div>
</div>
{:else if $adminSession}
<div class="profile-card">
<span class="profile-avatar">{initials($adminSession.name)}</span>
<div>
<strong>{$adminSession.name}</strong>
<span>{$adminSession.email}</span>
</div>
</div>
{:else}
<div class="profile-card guest">
<span class="profile-avatar">A</span>
<div>
<strong>Admin sign-in required</strong>
<span>Use `/admin` to authenticate</span>
</div>
</div>
{/if}
</header>
<main class="admin-content">
{#if isProtectedRoute && (!$sessionHydrated || isRestoringSession)}
<section class="locked-card loading-card">
<p class="eyebrow">Checking Session</p>
<h2>Restoring the Lean 101 admin workspace.</h2>
<p>Refreshing the current route with the saved operator session before prompting for sign-in.</p>
</section>
{:else if isProtectedRoute && !$adminSession}
<section class="locked-card">
<p class="eyebrow">Restricted</p>
<h2>Sign in through the Lean 101 Admin Panel to continue.</h2>
<p>Client access controls are only available inside the separate admin workspace.</p>
<a href="/admin">Go to admin sign-in</a>
</section>
{:else}
{@render children()}
{/if}
</main>
</div>
</div>
<style>
.admin-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
background: var(--color-bg-app);
color: var(--color-text-primary);
}
.admin-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.1rem;
border-right: 1px solid rgba(34, 54, 45, 0.12);
background: rgba(20, 29, 24, 0.96);
color: #f4f7f1;
}
.admin-brand {
display: inline-flex;
align-items: center;
gap: 0.8rem;
font-size: 1.05rem;
font-weight: 700;
}
.brand-mark,
.nav-icon,
.profile-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-weight: 700;
letter-spacing: 0.04em;
}
.brand-mark {
width: 2rem;
height: 2rem;
border-radius: 0.72rem;
color: #0f1713;
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
}
.admin-copy {
margin: 0;
color: rgba(244, 247, 241, 0.74);
line-height: 1.55;
}
.admin-nav {
display: grid;
gap: 0.4rem;
}
.admin-nav a {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.82rem 0.78rem;
border-radius: 0.9rem;
color: rgba(244, 247, 241, 0.88);
transition: background-color 140ms ease;
}
.admin-nav a:hover,
.admin-nav a.active {
background: rgba(207, 228, 184, 0.16);
}
.admin-nav a.active {
color: #ffffff;
}
.nav-icon {
width: 1.65rem;
height: 1.65rem;
border-radius: 0.58rem;
color: #0f1713;
background: linear-gradient(135deg, #cfe4b8 0%, #83c98b 100%);
font-size: 0.7rem;
}
.admin-footer {
margin-top: auto;
display: grid;
gap: 0.6rem;
}
.admin-footer a,
.admin-footer button {
padding: 0.82rem 0.88rem;
border: 1px solid rgba(244, 247, 241, 0.14);
border-radius: 0.88rem;
background: rgba(255, 255, 255, 0.04);
color: inherit;
text-align: left;
cursor: pointer;
}
.admin-main {
min-width: 0;
display: flex;
flex-direction: column;
}
.admin-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.4rem;
border-bottom: 1px solid rgba(34, 54, 45, 0.1);
background: rgba(247, 248, 244, 0.85);
backdrop-filter: blur(12px);
}
.eyebrow {
margin: 0 0 0.18rem;
color: #66806e;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.admin-topbar h1 {
margin: 0;
font-size: 1.7rem;
}
.profile-card {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.45rem 0.52rem;
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 0.95rem;
background: rgba(255, 255, 255, 0.82);
}
.profile-avatar {
width: 2.2rem;
height: 2.2rem;
border-radius: 999px;
color: #ffffff;
background: linear-gradient(135deg, #4f8860 0%, #203028 100%);
}
.profile-card strong,
.profile-card span {
display: block;
}
.profile-card span {
margin-top: 0.14rem;
color: #6b7f72;
font-size: 0.82rem;
}
.guest .profile-avatar {
background: linear-gradient(135deg, #c4d0c8 0%, #7b8b80 100%);
}
.admin-content {
min-width: 0;
padding: 1.4rem;
}
.locked-card {
max-width: 42rem;
padding: 1.35rem;
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.82);
box-shadow: none;
}
.loading-card {
min-height: 10rem;
}
.locked-card h2,
.locked-card p {
margin: 0;
}
.locked-card h2 {
margin-top: 0.35rem;
font-size: clamp(1.8rem, 3vw, 2.3rem);
}
.locked-card p:last-of-type {
margin-top: 0.45rem;
color: #5d7166;
}
.locked-card a {
display: inline-flex;
margin-top: 1rem;
padding: 0.82rem 0.95rem;
border-radius: 0.9rem;
background: #203028;
color: #ffffff;
font-weight: 600;
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 1fr;
}
.admin-sidebar {
border-right: none;
border-bottom: 1px solid rgba(34, 54, 45, 0.12);
}
}
@media (max-width: 720px) {
.admin-topbar {
flex-direction: column;
align-items: flex-start;
}
.admin-content {
padding: 1rem;
}
}
</style>
@@ -4,6 +4,9 @@
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte'; import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte'; import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte'; import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
import { currentChangelog } from '$lib/changelog';
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
@@ -17,6 +20,8 @@
canOpenEditor as sessionCanOpenEditor, canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator, canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster, canOpenMixMaster as sessionCanOpenMixMaster,
canOpenCustomerOrdering as sessionCanOpenCustomerOrdering,
canManageOrdering as sessionCanManageOrdering,
canOpenProductCosting as sessionCanOpenProductCosting, canOpenProductCosting as sessionCanOpenProductCosting,
canOpenReporting as sessionCanOpenReporting, canOpenReporting as sessionCanOpenReporting,
canOpenSettings as sessionCanOpenSettings, canOpenSettings as sessionCanOpenSettings,
@@ -36,6 +41,7 @@
footerLinks, footerLinks,
matchesRoute, matchesRoute,
mixCalculatorItem, mixCalculatorItem,
orderingItem,
pageTitle, pageTitle,
productCostingItem, productCostingItem,
reportingItem, reportingItem,
@@ -65,6 +71,10 @@
let userMenuOpen = $state(false); let userMenuOpen = $state(false);
let navOpen = $state(false); let navOpen = $state(false);
let showBottomNav = $state(false); let showBottomNav = $state(false);
let whatsNewOpen = $state(false);
// The user identity we've already run the "what's new" check for this mount,
// so the dialog is evaluated once per login rather than on every navigation.
let whatsNewCheckedFor = $state<string | null>(null);
let isRestoringSession = $state(false); let isRestoringSession = $state(false);
let restoredSessionKey = $state<string | null>(null); let restoredSessionKey = $state<string | null>(null);
let seededSearchItems = $state<SearchItem[]>([]); let seededSearchItems = $state<SearchItem[]>([]);
@@ -102,6 +112,17 @@
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null); const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession)); const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null); const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
// Ordering serves two audiences: internal staff get the management console
// (/ordering/manage), customers get the catalogue (/ordering).
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
const visibleOrderingItem = $derived(
canManageOrdering
? { ...orderingItem, href: '/ordering/manage', label: 'Order Management', shortLabel: 'OM' }
: canOpenCustomerOrdering
? orderingItem
: 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);
// Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the // Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the
@@ -117,6 +138,7 @@
...visibleWorkingDocumentItems ...visibleWorkingDocumentItems
], ],
throughput: visibleThroughputItem, throughput: visibleThroughputItem,
ordering: visibleOrderingItem,
reporting: visibleReportingItem reporting: visibleReportingItem
}) })
); );
@@ -331,6 +353,33 @@
goto(workspaceHomeHref, { replaceState: true }); goto(workspaceHomeHref, { replaceState: true });
}); });
// Surface the release notes once per version per user, right after login.
// hasSeenVersion keeps this to a single appearance: once dismissed (which
// records the version), it won't return until the next version ships.
$effect(() => {
if (!$sessionHydrated || !$clientSession || !currentChangelog) {
return;
}
const userKey = `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}`;
if (whatsNewCheckedFor === userKey) {
return;
}
whatsNewCheckedFor = userKey;
if (!hasSeenVersion(userKey, currentChangelog.version)) {
whatsNewOpen = true;
}
});
function dismissWhatsNew() {
if ($clientSession && currentChangelog) {
const userKey = `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}`;
markVersionSeen(userKey, currentChangelog.version);
}
whatsNewOpen = false;
}
onMount(() => { onMount(() => {
syncViewport(); syncViewport();
@@ -639,6 +688,10 @@
{/if} {/if}
{#if $clientSession && whatsNewOpen && currentChangelog}
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
{/if}
{#if $clientSession && paletteOpen} {#if $clientSession && paletteOpen}
<div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}> <div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}>
<div <div
@@ -0,0 +1,383 @@
<script lang="ts">
import { api } from '$lib/api';
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { ShoppingCart, LogOut } from 'lucide-svelte';
import { clientSession, sessionHydrated } from '$lib/session';
let { children } = $props();
const isRootRoute = $derived(page.url.pathname === '/');
const currentYear = new Date().getFullYear();
const userName = $derived($clientSession?.name ?? '');
const userInitials = $derived(
($clientSession?.name ?? '')
.split(' ')
.slice(0, 2)
.map((word: string) => word[0])
.join('')
.toUpperCase() || '?'
);
const navItems = [{ href: '/ordering', label: 'Order Catalogue', icon: ShoppingCart }];
function isActive(href: string) {
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
}
async function signOut() {
try {
await api.clientLogout();
} catch {
// Clearing the local session is the safe fallback.
} finally {
clientSession.clear();
await goto('/', { replaceState: true });
}
}
// Keep the saved session fresh on reload (mirrors the workspace shell).
let restoredSessionKey = $state<string | null>(null);
$effect(() => {
const hydrated = $sessionHydrated;
const sessionKey = $clientSession ? `${$clientSession.role}:${$clientSession.email}:${$clientSession.user_id ?? ''}` : null;
if (!hydrated || !sessionKey || restoredSessionKey === sessionKey) {
return;
}
restoredSessionKey = sessionKey;
api
.clientSession()
.then((session) => {
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredSessionKey = null;
clientSession.clear();
});
});
// Signed-out customers go back to the sign-in screen; the portal landing is
// always the catalogue, never the internal dashboard/login root.
$effect(() => {
if (!$sessionHydrated) return;
if (!$clientSession && !isRootRoute) {
goto('/', { replaceState: true });
} else if ($clientSession && isRootRoute) {
goto('/ordering', { replaceState: true });
}
});
</script>
<svelte:head>
<title>Customer Ordering Portal | Hunter Premium Produce</title>
</svelte:head>
{#if !$clientSession}
{#if isRootRoute}
{@render children()}
{:else}
<div class="loading-screen">
<p>Returning you to sign in…</p>
</div>
{/if}
{:else}
<div class="portal">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark"><ShoppingCart size={20} strokeWidth={2} /></span>
<div class="brand-text">
<span class="brand-name">Hunter Premium Produce</span>
<span class="brand-sub">Customer Ordering Portal</span>
</div>
</div>
<nav class="nav" aria-label="Customer portal navigation">
{#each navItems as item}
{@const Icon = item.icon}
<a class="nav-row" class:active={isActive(item.href)} href={item.href}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.85} /></span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="sidebar-foot">
<div class="account">
<span class="avatar">{userInitials}</span>
<span class="account-name">{userName}</span>
</div>
<button class="signout" type="button" onclick={signOut}>
<LogOut size={16} strokeWidth={1.9} />
<span>Sign out</span>
</button>
<small class="copyright">&copy; {currentYear} Hunter Premium Produce</small>
</div>
</aside>
<div class="main">
<header class="topbar">
<h1>Customer Ordering Portal</h1>
<div class="topbar-account">
<span class="avatar small">{userInitials}</span>
<span class="topbar-name">{userName}</span>
</div>
</header>
<main class="content">
{@render children()}
</main>
</div>
</div>
{/if}
<style>
.portal {
display: grid;
grid-template-columns: 16rem minmax(0, 1fr);
min-height: 100vh;
background: #f4f7f4;
color: #1f2a24;
}
.loading-screen {
display: grid;
place-items: center;
min-height: 100vh;
color: #5f7266;
background: #f4f7f4;
}
/* ── Sidebar ─────────────────────────────────────────────── */
.sidebar {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.4rem 1.05rem;
background: #1f3a2c;
color: #e7efe9;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 0.7rem;
padding-bottom: 1.1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.4rem;
height: 2.4rem;
border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.12);
color: #fff;
flex-shrink: 0;
}
.brand-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.brand-name {
font-size: 0.98rem;
font-weight: 700;
line-height: 1.2;
}
.brand-sub {
font-size: 0.78rem;
color: rgba(231, 239, 233, 0.7);
}
.nav {
display: grid;
gap: 0.25rem;
}
.nav-row {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.7rem 0.8rem;
border-radius: 0.75rem;
color: rgba(231, 239, 233, 0.85);
text-decoration: none;
font-size: 0.92rem;
font-weight: 500;
transition: background-color 140ms ease, color 140ms ease;
}
.nav-row:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.nav-row.active {
background: #fff;
color: #1f3a2c;
font-weight: 600;
}
.nav-icon {
display: inline-flex;
flex-shrink: 0;
}
.sidebar-foot {
margin-top: auto;
display: grid;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.account {
display: flex;
align-items: center;
gap: 0.6rem;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 0.78rem;
font-weight: 700;
flex-shrink: 0;
}
.avatar.small {
width: 1.85rem;
height: 1.85rem;
background: #1f3a2c;
}
.account-name {
font-size: 0.86rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.signout {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 0.7rem;
background: transparent;
color: #e7efe9;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
transition: background-color 140ms ease;
}
.signout:hover {
background: rgba(255, 255, 255, 0.1);
}
.copyright {
font-size: 0.7rem;
color: rgba(231, 239, 233, 0.55);
}
/* ── Main ────────────────────────────────────────────────── */
.main {
display: flex;
flex-direction: column;
min-width: 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.05rem 1.6rem;
background: #fff;
border-bottom: 1px solid rgba(31, 58, 44, 0.1);
position: sticky;
top: 0;
z-index: 5;
}
.topbar h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: #1f3a2c;
}
.topbar-account {
display: flex;
align-items: center;
gap: 0.55rem;
}
.topbar-name {
font-size: 0.88rem;
font-weight: 600;
color: #1f2a24;
}
.content {
flex: 1;
padding: 1.6rem;
min-width: 0;
}
@media (max-width: 820px) {
.portal {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.brand {
border-bottom: none;
padding-bottom: 0;
flex: 1;
}
.nav {
grid-auto-flow: column;
}
.sidebar-foot {
margin-top: 0;
border-top: none;
padding-top: 0;
grid-auto-flow: column;
align-items: center;
}
.copyright {
display: none;
}
}
</style>
@@ -0,0 +1,173 @@
<script lang="ts">
import { Sparkles } from 'lucide-svelte';
import type { ChangelogEntry } from '$lib/changelog';
let { entry, onClose }: { entry: ChangelogEntry; onClose: () => void } = $props();
const releaseDate = $derived(
new Date(`${entry.date}T00:00:00`).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
})
);
</script>
<div class="whats-new-backdrop" role="presentation" onclick={onClose}>
<div
class="whats-new"
role="dialog"
aria-modal="true"
aria-labelledby="whats-new-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => {
if (event.key === 'Escape') {
onClose();
}
}}
>
<div class="whats-new-head">
<span class="whats-new-mark"><Sparkles size={20} strokeWidth={1.75} /></span>
<div>
<p class="whats-new-kicker">What's new · v{entry.version}</p>
<h2 id="whats-new-title">A few updates in this release</h2>
<p class="whats-new-date">{releaseDate}</p>
</div>
</div>
<ul class="whats-new-list">
{#each entry.highlights as highlight}
<li>{highlight}</li>
{/each}
</ul>
<div class="whats-new-actions">
<button class="whats-new-button" type="button" onclick={onClose}>Got it</button>
</div>
</div>
</div>
<style>
h2,
p {
margin: 0;
}
.whats-new-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(11, 18, 14, 0.45);
backdrop-filter: blur(10px);
}
.whats-new {
width: min(34rem, 100%);
display: grid;
gap: 1.15rem;
padding: 1.5rem;
border: 1px solid var(--color-border);
border-radius: 1.1rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.22);
}
.whats-new-head {
display: flex;
align-items: flex-start;
gap: 0.9rem;
}
.whats-new-mark {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.6rem;
height: 2.6rem;
border-radius: 0.82rem;
color: var(--color-on-brand);
background: var(--color-brand);
}
.whats-new-kicker {
color: var(--color-brand);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.whats-new-head h2 {
margin-top: 0.22rem;
font-size: 1.28rem;
letter-spacing: -0.02em;
}
.whats-new-date {
margin-top: 0.18rem;
color: var(--color-text-muted);
font-size: 0.84rem;
}
.whats-new-list {
display: grid;
gap: 0.7rem;
margin: 0;
padding: 0;
list-style: none;
}
.whats-new-list li {
position: relative;
padding-left: 1.5rem;
color: var(--color-text-secondary);
line-height: 1.5;
}
.whats-new-list li::before {
content: '';
position: absolute;
top: 0.5rem;
left: 0.3rem;
width: 0.46rem;
height: 0.46rem;
border-radius: 999px;
background: var(--color-brand);
}
.whats-new-actions {
display: flex;
justify-content: flex-end;
}
.whats-new-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.6rem;
padding: 0.72rem 1.3rem;
border: 1px solid var(--color-brand);
border-radius: var(--radius-control);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
cursor: pointer;
transition: background-color 160ms ease, border-color 160ms ease;
}
.whats-new-button:hover {
background: var(--color-brand-hover);
border-color: var(--color-brand-hover);
}
.whats-new-button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
outline-offset: 2px;
}
</style>
@@ -521,13 +521,19 @@
padding: 0.78rem 0.82rem; padding: 0.78rem 0.82rem;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 0.6rem; border-radius: 0.6rem;
background: #fff; background: var(--color-input-bg);
color: var(--text); color: var(--text);
transition: transition:
border-color 160ms ease, border-color 160ms ease,
box-shadow 160ms ease; box-shadow 160ms ease;
} }
input::placeholder,
textarea::placeholder {
color: var(--color-text-muted);
opacity: 1;
}
input:focus-visible, input:focus-visible,
select:focus-visible, select:focus-visible,
textarea:focus-visible { textarea:focus-visible {
@@ -203,8 +203,9 @@
</aside> </aside>
<style> <style>
/* Light monochrome rail with a dark selected pill. The rail keeps its own palette /* Monochrome rail with a blue selected pill. Colours come from the --sidebar-*
via the --sidebar-* tokens, independent of the content theme. */ tokens, which are overridden in dark mode (see theme.css) so the rail themes
alongside the content instead of staying a bright light strip. */
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -6,6 +6,7 @@ import {
Layers, Layers,
LayoutDashboard, LayoutDashboard,
ShieldCheck, ShieldCheck,
ShoppingCart,
TrendingUp TrendingUp
} from 'lucide-svelte'; } from 'lucide-svelte';
import type { ComponentType } from 'svelte'; import type { ComponentType } from 'svelte';
@@ -112,6 +113,14 @@ export const throughputItem: NavItem = {
badge: 'test' badge: 'test'
}; };
export const orderingItem: NavItem = {
href: '/ordering',
label: 'Ordering',
shortLabel: 'OR',
icon: ShoppingCart,
moduleKey: 'ordering'
};
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.
@@ -210,6 +219,7 @@ export function buildClientNavEntries(visible: {
dashboard?: NavItem | null; dashboard?: NavItem | null;
costing: NavItem[]; costing: NavItem[];
throughput?: NavItem | null; throughput?: NavItem | null;
ordering?: NavItem | null;
reporting?: NavItem | null; reporting?: NavItem | null;
}): NavEntry[] { }): NavEntry[] {
const entries: NavEntry[] = []; const entries: NavEntry[] = [];
@@ -225,6 +235,10 @@ export function buildClientNavEntries(visible: {
}); });
} }
if (visible.ordering) {
entries.push({ kind: 'item', item: visible.ordering });
}
if (visible.throughput) { if (visible.throughput) {
entries.push({ kind: 'item', item: visible.throughput }); entries.push({ kind: 'item', item: visible.throughput });
} }
+22 -1
View File
@@ -34,6 +34,9 @@
--color-surface-hover: oklch(0.955 0.004 240); --color-surface-hover: oklch(0.955 0.004 240);
--color-surface-selected: color-mix(in srgb, var(--color-brand) 10%, var(--color-bg-surface)); --color-surface-selected: color-mix(in srgb, var(--color-brand) 10%, var(--color-bg-surface));
/* ── Form inputs: a touch recessed from the card surface ── */
--color-input-bg: var(--panel-soft);
/* ── Borders ────────────────────────────────────────────── */ /* ── Borders ────────────────────────────────────────────── */
--color-border: oklch(0.92 0.005 240); --color-border: oklch(0.92 0.005 240);
--color-divider: oklch(0.94 0.004 240); --color-divider: oklch(0.94 0.004 240);
@@ -117,10 +120,28 @@
--color-surface-hover: oklch(0.27 0.006 240); --color-surface-hover: oklch(0.27 0.006 240);
--color-surface-selected: color-mix(in srgb, var(--color-brand) 14%, var(--color-bg-surface)); --color-surface-selected: color-mix(in srgb, var(--color-brand) 14%, var(--color-bg-surface));
/* Form inputs: sit just above the card so fields don't read as
black holes punched into the surface. */
--color-input-bg: oklch(0.25 0.005 240);
/* ── Borders ────────────────────────────────────────────── */ /* ── Borders ────────────────────────────────────────────── */
--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
rendering as a bright light strip in dark mode. Active item keeps
the blue pill from light mode. */
--sidebar-bg: oklch(0.2 0.005 240);
--sidebar-hover: oklch(0.27 0.006 240);
--sidebar-active-bg: #3290d9;
--sidebar-active-text: var(--color-on-brand);
--sidebar-border: oklch(0.3 0.006 240);
--sidebar-text: oklch(0.78 0.006 240);
--sidebar-text-strong: oklch(0.96 0.003 240);
--sidebar-text-muted: oklch(0.6 0.008 240);
--sidebar-icon: oklch(0.68 0.008 240);
--sidebar-logo-bg: oklch(0.26 0.006 240);
/* ── Text (neutral) ─────────────────────────────────────── */ /* ── Text (neutral) ─────────────────────────────────────── */
--color-text-primary: oklch(0.96 0.003 240); --color-text-primary: oklch(0.96 0.003 240);
--color-text-secondary: oklch(0.78 0.006 240); --color-text-secondary: oklch(0.78 0.006 240);
@@ -448,7 +469,7 @@ a {
padding: 0.82rem 0.9rem; padding: 0.82rem 0.9rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: var(--panel-soft); background: var(--color-input-bg);
color: var(--color-text-primary); color: var(--color-text-primary);
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1), transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 160ms cubic-bezier(0.22, 1, 0.36, 1); border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 160ms cubic-bezier(0.22, 1, 0.36, 1);
+8 -18
View File
@@ -6,18 +6,17 @@ export type ResolvedTheme = 'light' | 'dark';
const STORAGE_KEY = 'theme'; const STORAGE_KEY = 'theme';
function systemTheme(): ResolvedTheme { // Dark mode is strictly opt-in. Light is the default and the OS colour scheme is
return browser && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; // deliberately ignored: only an explicit stored 'dark' resolves to dark, so the
} // app never loads dark on its own. The legacy 'system' value collapses to light.
function resolve(pref: ThemePreference): ResolvedTheme { function resolve(pref: ThemePreference): ResolvedTheme {
return pref === 'system' ? systemTheme() : pref; return pref === 'dark' ? 'dark' : 'light';
} }
function readPreference(): ThemePreference { function readPreference(): ThemePreference {
if (!browser) return 'system'; if (!browser) return 'light';
const stored = window.localStorage.getItem(STORAGE_KEY); const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system'; return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'light';
} }
function applyResolved(theme: ResolvedTheme) { function applyResolved(theme: ResolvedTheme) {
@@ -26,10 +25,10 @@ function applyResolved(theme: ResolvedTheme) {
} }
} }
/** The user's stored choice (may be 'system'). */ /** The user's stored choice (may be the legacy 'system'). */
export const themePreference = writable<ThemePreference>(readPreference()); export const themePreference = writable<ThemePreference>(readPreference());
/** The theme actually painted right now ('system' collapsed to light/dark). */ /** The theme actually painted right now. */
export const resolvedTheme = writable<ResolvedTheme>(resolve(readPreference())); export const resolvedTheme = writable<ResolvedTheme>(resolve(readPreference()));
if (browser) { if (browser) {
@@ -39,15 +38,6 @@ if (browser) {
resolvedTheme.set(next); resolvedTheme.set(next);
applyResolved(next); applyResolved(next);
}); });
// Follow the OS only while the user is on 'system'.
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (readPreference() === 'system') {
const next = systemTheme();
resolvedTheme.set(next);
applyResolved(next);
}
});
} }
/** Flip between light and dark, committing to an explicit preference. */ /** Flip between light and dark, committing to an explicit preference. */
+165
View File
@@ -637,3 +637,168 @@ export type ThroughputProductCreateInput = {
}; };
export type ThroughputProductUpdateInput = Partial<ThroughputProductCreateInput>; export type ThroughputProductUpdateInput = Partial<ThroughputProductCreateInput>;
// --- B2B ordering portal ----------------------------------------------------
export type OrderingPriceInfo = {
unit_price: number | null;
price_source: 'fixed' | 'contract' | 'price_list' | 'tiered' | 'base' | 'quote';
price_rule_id: number | null;
discount_percent: number;
requires_quote: boolean;
label: string;
};
export type CatalogueProduct = {
id: number;
name: string;
sku: string;
description?: string | null;
category: string;
image_url?: string | null;
unit_size?: string | null;
unit_of_measure: string;
min_order_quantity: number;
stock_status: string;
active: boolean;
requires_quote: boolean;
base_price?: number | null;
created_at?: string;
price?: OrderingPriceInfo;
};
export type OrderLine = {
id: number;
product_id: number;
product_name: string;
product_sku: string;
quantity: number;
unit_price: number | null;
line_total: number | null;
requires_quote: boolean;
price_source: string;
discount_percent: number;
notes?: string | null;
// admin-only
resolved_unit_price?: number | null;
admin_override_price?: number | null;
admin_override_reason?: string | null;
price_rule_id?: number | null;
};
export type OrderStatusHistoryEntry = {
id: number;
from_status: string | null;
to_status: string;
actor_type: string;
actor_name?: string | null;
note?: string | null;
created_at: string;
};
export type Order = {
id: number;
order_number: string | null;
status: string;
client_account_id: number;
created_by_name?: string | null;
purchase_order_number?: string | null;
delivery_notes?: string | null;
requested_delivery_date?: string | null;
fulfilment_method: string;
subtotal_ex_gst: number;
requires_quote: boolean;
submitted_at?: string | null;
created_at: string;
updated_at: string;
editable: boolean;
lines: OrderLine[];
// admin-only
raw_status?: string;
admin_notes?: string | null;
reopened?: boolean;
xero_status?: string;
xero_invoice_id?: string | null;
customer_name?: string | null;
status_history?: OrderStatusHistoryEntry[];
notifications?: { channel: string; recipients: string[]; delivered: boolean; detail: string }[];
xero_result?: { status: string; stubbed: boolean; message: string; xero_invoice_id: string | null };
};
export type OrderLineInput = { product_id: number; quantity: number; notes?: string | null };
export type DraftOrderInput = {
lines: OrderLineInput[];
purchase_order_number?: string | null;
delivery_notes?: string | null;
requested_delivery_date?: string | null;
fulfilment_method?: string;
};
export type OrderingCustomer = {
id: number;
name: string;
client_code: string;
tenant_id: string;
status: string;
notes?: string | null;
user_count: number;
price_list_id: number | null;
discount_percent: number;
created_at: string;
};
export type OrderingCustomerUser = {
id: number;
client_account_id: number;
full_name: string;
email: string;
role: string;
status: string;
created_at: string;
};
export type PriceTierInput = { min_quantity: number; unit_price: number };
export type CustomerPricing = {
customer_id: number;
price_list_id: number | null;
discount_percent: number;
product_prices: {
id: number;
product_id: number;
unit_price: number | null;
rule_type: string;
contract_reference?: string | null;
notes?: string | null;
active: boolean;
tiers: { id: number; min_quantity: number; unit_price: number }[];
}[];
};
export type CustomerVisibilityRow = {
product_id: number;
name: string;
sku: string;
category: string;
visible: boolean;
};
export type OrderingNotificationSettings = {
internal_recipients: string | null;
send_customer_confirmation: boolean;
require_po_number: boolean;
from_email: string | null;
};
export type XeroStatus = {
connection: { configured: boolean; mode: string; base_url: string; checked_at: string; missing_env: string[] };
recent_syncs: {
id: number;
order_id: number;
status: string;
xero_invoice_id: string | null;
response_message: string | null;
created_at: string;
}[];
};
+37
View File
@@ -0,0 +1,37 @@
import { browser } from '$app/environment';
/**
* Tracks which release-notes version a user has already seen, so the "What's
* new" dialog shows exactly once per version per user rather than on every
* login. State is kept client-side in localStorage, keyed per user, so a fresh
* browser/device will re-show the current version's notes once.
*/
const STORAGE_PREFIX = 'hsf:whats-new:seen';
function storageKey(userKey: string): string {
return `${STORAGE_PREFIX}:${userKey}`;
}
/**
* True if this user has already acknowledged the given version. Errs on the
* side of "seen" when storage is unavailable (SSR, private mode) so we never
* pop the dialog where we can't record that it was dismissed.
*/
export function hasSeenVersion(userKey: string, version: string): boolean {
if (!browser) return true;
try {
return window.localStorage.getItem(storageKey(userKey)) === version;
} catch {
return true;
}
}
/** Record that this user has seen the given version's release notes. */
export function markVersionSeen(userKey: string, version: string): void {
if (!browser) return;
try {
window.localStorage.setItem(storageKey(userKey), version);
} catch {
// A storage failure just means the dialog may reappear next login; harmless.
}
}
+57
View File
@@ -117,6 +117,52 @@ export function canOpenReporting(session: AppSession | null | undefined) {
return canOpenProducts(session); return canOpenProducts(session);
} }
// Internal staff who manage the customer ordering portal (catalogue, pricing,
// order lifecycle). These are Hunter Stock Feeds users signing in via the
// internal access system — not customers.
export function canManageOrdering(session: AppSession | null | undefined) {
return !!session && session.role === 'internal' && hasPermission(session, 'manage_ordering');
}
// B2B customers (ClientUser accounts) who browse the catalogue and place orders.
export function canOpenCustomerOrdering(session: AppSession | null | undefined) {
return !!session && session.role !== 'internal' && hasModuleAccess(session, 'ordering');
}
// Either audience may open the Ordering area (the nav/route resolves which view).
export function canOpenOrdering(session: AppSession | null | undefined) {
return canManageOrdering(session) || canOpenCustomerOrdering(session);
}
export function canPlaceOrders(session: AppSession | null | undefined) {
return !!session && session.role !== 'internal' && hasModuleAccess(session, 'ordering', 'edit');
}
// A "customer portal" session is a B2B ordering customer (ClientUser) whose
// access is limited to ordering — they get the dedicated, stripped-down
// Customer Ordering Portal shell rather than the internal staff workspace.
// Internal staff and costing-portal client users (who also have mix/product/
// throughput access) keep the full workspace.
const STAFF_ONLY_MODULES = [
'mix_calculator',
'products',
'mix_master',
'operations_throughput',
'scenarios',
'raw_materials',
'client_access'
];
export function isCustomerPortalSession(session: AppSession | null | undefined) {
if (!session || session.role !== 'client') {
return false;
}
if (!canOpenCustomerOrdering(session)) {
return false;
}
return !STAFF_ONLY_MODULES.some((moduleKey) => hasModuleAccess(session, moduleKey));
}
export function canOpenSettings(session: AppSession | null | undefined) { export function canOpenSettings(session: AppSession | null | undefined) {
if (!session) { if (!session) {
return false; return false;
@@ -167,10 +213,20 @@ export const routeAccessRules: RouteAccessRule[] = [
path: '/throughput', path: '/throughput',
roles: ['admin', 'operations', 'full', 'client'], roles: ['admin', 'operations', 'full', 'client'],
matches: (pathname) => hasPathPrefix(pathname, '/throughput') matches: (pathname) => hasPathPrefix(pathname, '/throughput')
},
{
path: '/ordering',
roles: ['admin', 'full', 'client'],
matches: (pathname) => hasPathPrefix(pathname, '/ordering')
} }
]; ];
export function getDefaultRouteForRole(session: AppSession | null | undefined) { export function getDefaultRouteForRole(session: AppSession | null | undefined) {
// B2B ordering customers land directly in the ordering portal.
if (isCustomerPortalSession(session)) {
return '/ordering';
}
const role = getWorkspaceRole(session); const role = getWorkspaceRole(session);
if (role === 'operations') { if (role === 'operations') {
@@ -213,6 +269,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
if (pathname.startsWith('/settings')) return canOpenSettings(session); if (pathname.startsWith('/settings')) return canOpenSettings(session);
if (pathname.startsWith('/client-access')) return canOpenClientAccess(session); if (pathname.startsWith('/client-access')) return canOpenClientAccess(session);
if (pathname.startsWith('/throughput')) return canOpenThroughput(session); if (pathname.startsWith('/throughput')) return canOpenThroughput(session);
if (pathname.startsWith('/ordering')) return canOpenOrdering(session);
return true; return true;
} }
+9 -5
View File
@@ -7,15 +7,19 @@
import '$lib/theme'; import '$lib/theme';
import { beforeNavigate, afterNavigate } from '$app/navigation'; import { beforeNavigate, afterNavigate } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import AdminShell from '$lib/components/AdminShell.svelte';
import ClientShell from '$lib/components/ClientShell.svelte'; import ClientShell from '$lib/components/ClientShell.svelte';
import CustomerPortalShell from '$lib/components/CustomerPortalShell.svelte';
import Toast from '$lib/components/Toast.svelte'; import Toast from '$lib/components/Toast.svelte';
import { clientSession } from '$lib/session';
import { isCustomerPortalSession } from '$lib/workspace-access';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
let { children } = $props(); let { children } = $props();
const isAdminRoute = $derived(page.url.pathname === '/admin' || page.url.pathname.startsWith('/admin/'));
const isPrintableRoute = $derived(page.url.pathname.startsWith('/mix-calculator/') && page.url.pathname.endsWith('/print')); const isPrintableRoute = $derived(page.url.pathname.startsWith('/mix-calculator/') && page.url.pathname.endsWith('/print'));
// B2B ordering customers get the dedicated, stripped-down portal shell;
// internal staff and costing-portal users keep the full workspace shell.
const isCustomerPortal = $derived(isCustomerPortalSession($clientSession));
let navToastId: string | null = null; let navToastId: string | null = null;
let navTimer: ReturnType<typeof setTimeout> | null = null; let navTimer: ReturnType<typeof setTimeout> | null = null;
@@ -41,10 +45,10 @@
{#if isPrintableRoute} {#if isPrintableRoute}
{@render children()} {@render children()}
{:else if isAdminRoute} {:else if isCustomerPortal}
<AdminShell> <CustomerPortalShell>
{@render children()} {@render children()}
</AdminShell> </CustomerPortalShell>
{:else} {:else}
<ClientShell> <ClientShell>
{@render children()} {@render children()}
+10 -3
View File
@@ -63,9 +63,16 @@
try { try {
// Authenticates against the internal Hunter Stock Feeds role/permission // Authenticates against the internal Hunter Stock Feeds role/permission
// system. The response is shape-compatible with the legacy client // system first. If that fails (e.g. a B2B ordering-portal customer, who
// session, so the rest of the app continues to work unchanged. // lives in the ClientUser table and signs in with the shared client
const session = await api.internalLogin(email, password); // password), fall back to the client login. Both responses are
// shape-compatible with the client session.
let session;
try {
session = await api.internalLogin(email, password);
} catch {
session = await api.clientLogin(email, password);
}
const targetHref = getWorkspaceHomeHref(session); const targetHref = getWorkspaceHomeHref(session);
postLoginRedirecting = targetHref !== '/'; postLoginRedirecting = targetHref !== '/';
clientSession.set(session); clientSession.set(session);
-394
View File
@@ -1,394 +0,0 @@
<script lang="ts">
import { api } from '$lib/api';
import { adminSession, sessionHydrated } from '$lib/session';
let { data } = $props();
let email = $state('');
let password = $state('');
let isLoggingIn = $state(false);
let loginError = $state('');
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No preview generated';
}
return new Intl.DateTimeFormat('en-NZ', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(value));
}
async function handleLogin(event: SubmitEvent) {
event.preventDefault();
loginError = '';
isLoggingIn = true;
try {
const session = await api.adminLogin(email, password);
adminSession.set(session);
} catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in';
} finally {
isLoggingIn = false;
}
}
const totalUsers = $derived(data.clients.reduce((sum, client) => sum + client.users.length, 0));
const totalFeatures = $derived(data.clients.reduce((sum, client) => sum + client.enabled_feature_count, 0));
</script>
<section class="hero-card">
<div class="hero-copy">
<p class="eyebrow">Lean 101 Admin Panel</p>
<h2>Separate operator login and client access controls from the Hunter Premium Produce workspace.</h2>
<p>Use this admin surface for internal access changes, export validation, and operator-only workflows.</p>
</div>
<div class="hero-stats">
<article>
<span>Managed clients</span>
<strong>{data.clients.length}</strong>
</article>
<article>
<span>Total users</span>
<strong>{totalUsers}</strong>
</article>
<article>
<span>Enabled features</span>
<strong>{totalFeatures}</strong>
</article>
</div>
</section>
{#if !$sessionHydrated}
<section class="signin-card loading-card">
<div class="signin-copy">
<p class="eyebrow">Checking Session</p>
<h3>Restoring the Lean 101 admin session before deciding whether sign-in is needed.</h3>
<p>The admin sign-in form only appears when no saved operator session is available.</p>
</div>
</section>
{:else if !$adminSession}
<section class="signin-card">
<div class="signin-copy">
<p class="eyebrow">Admin Sign-In</p>
<h3>Authenticate here to unlock the admin navigation and client access controls.</h3>
<p>The public client workspace no longer exposes this operator sign-in.</p>
</div>
<form class="signin-form" onsubmit={handleLogin}>
<input bind:value={email} type="email" autocomplete="username" placeholder="Email" />
<input bind:value={password} type="password" autocomplete="current-password" placeholder="Password" />
<button class="primary-button" type="submit" disabled={isLoggingIn}>
{isLoggingIn ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div class="signin-meta">
{#if loginError}
<strong>{loginError}</strong>
{/if}
</div>
</section>
{:else}
<section class="live-banner">
<div>
<p class="eyebrow">Session Active</p>
<h3>{$adminSession.name} is signed in to the Lean 101 Admin Panel.</h3>
<p>Open the client access workspace to manage users, feature flags, and the Power BI export preview.</p>
</div>
<div class="live-actions">
<a class="primary-button" href="/admin/client-access">Open Client Access</a>
<a class="secondary-button" href="/">View Hunter workspace</a>
</div>
</section>
{/if}
<section class="detail-grid">
<article class="surface-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Scope</p>
<h3>What belongs in admin</h3>
</div>
</div>
<div class="bullet-list">
<article>
<strong>Client access control</strong>
<span>Manage new users, existing users, and feature access by client.</span>
</article>
<article>
<strong>Power BI export validation</strong>
<span>Verify the live export payload after each access change.</span>
</article>
<article>
<strong>Operator-only sign-in</strong>
<span>Keep internal authentication separate from the client workspace at `/`.</span>
</article>
</div>
</article>
<article class="surface-card">
<div class="card-toolbar">
<div>
<p class="eyebrow">Preview Snapshot</p>
<h3>Current export summary</h3>
<p>Last generated {formatDate(data.exportPreview.generated_at)}</p>
</div>
</div>
<div class="preview-stats">
<article>
<span>Client rows</span>
<strong>{data.exportPreview.client_rows.length}</strong>
</article>
<article>
<span>User rows</span>
<strong>{data.exportPreview.user_rows.length}</strong>
</article>
<article>
<span>Feature rows</span>
<strong>{data.exportPreview.feature_rows.length}</strong>
</article>
</div>
</article>
</section>
<style>
h2,
h3,
p {
margin: 0;
}
.eyebrow {
color: #6e8576;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero-card,
.signin-card,
.live-banner,
.surface-card {
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.84);
box-shadow: none;
}
.hero-card,
.signin-card,
.live-banner,
.detail-grid {
margin-bottom: 1.25rem;
}
.hero-card,
.signin-card,
.live-banner,
.surface-card {
padding: 1.25rem;
}
.hero-card,
.hero-stats,
.detail-grid,
.preview-stats {
display: grid;
gap: 1rem;
}
.hero-card {
grid-template-columns: minmax(0, 1.2fr) 0.85fr;
align-items: end;
}
.hero-copy h2 {
margin: 0.35rem 0 0.45rem;
max-width: 16ch;
font-size: clamp(2rem, 3vw, 2.6rem);
line-height: 1.02;
}
.hero-copy p:last-child,
.signin-copy p:last-child,
.live-banner p:last-child,
.bullet-list span,
.preview-stats span,
.card-toolbar p {
color: #5f7266;
}
.hero-stats,
.preview-stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hero-stats article,
.preview-stats article {
padding: 1rem;
border-radius: 1rem;
background: rgba(243, 247, 241, 0.95);
border: 1px solid rgba(34, 54, 45, 0.08);
}
.hero-stats span,
.preview-stats span {
display: block;
font-size: 0.84rem;
}
.hero-stats strong,
.preview-stats strong {
display: block;
margin-top: 0.35rem;
font-size: 1.8rem;
}
.signin-card {
display: grid;
grid-template-columns: 1.2fr 1fr auto;
gap: 1rem;
align-items: center;
}
.loading-card {
grid-template-columns: 1fr;
min-height: 10rem;
}
.signin-copy h3,
.live-banner h3,
.card-toolbar h3 {
margin: 0.28rem 0 0.35rem;
font-size: 1.2rem;
}
.signin-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.signin-form input {
width: 100%;
padding: 0.9rem 0.95rem;
border: 1px solid rgba(34, 54, 45, 0.12);
border-radius: 0.85rem;
background: rgba(248, 251, 249, 0.92);
}
.signin-meta {
display: grid;
gap: 0.35rem;
justify-items: end;
color: #5f7266;
font-size: 0.9rem;
}
.signin-meta strong {
color: #b33636;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.85rem;
padding: 0.85rem 1rem;
font-weight: 600;
text-decoration: none;
}
.primary-button {
border: none;
color: #fff;
background: var(--color-brand);
box-shadow: none;
}
.secondary-button {
border: 1px solid rgba(34, 54, 45, 0.12);
color: #203028;
background: rgba(255, 255, 255, 0.9);
}
.primary-button:disabled {
opacity: 0.72;
cursor: wait;
}
.live-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.live-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.detail-grid {
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.9fr);
}
.card-toolbar {
margin-bottom: 1rem;
}
.bullet-list {
display: grid;
gap: 0.9rem;
}
.bullet-list article {
padding: 0.95rem 1rem;
border-radius: 1rem;
background: rgba(243, 247, 241, 0.95);
border: 1px solid rgba(34, 54, 45, 0.08);
}
.bullet-list strong,
.preview-stats strong {
font-weight: 700;
}
.bullet-list span {
display: block;
margin-top: 0.25rem;
}
@media (max-width: 1120px) {
.hero-card,
.signin-card,
.detail-grid,
.hero-stats,
.preview-stats {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.live-banner {
flex-direction: column;
align-items: flex-start;
}
.signin-form {
grid-template-columns: 1fr;
}
}
</style>
-41
View File
@@ -1,41 +0,0 @@
import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api';
export async function load({ fetch }) {
if (!hasStoredAdminSession()) {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: []
}
};
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
return {
clients,
exportPreview
};
} catch {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: []
}
};
}
}
@@ -1,7 +0,0 @@
<script lang="ts">
import ClientAccessWorkspace from '$lib/components/ClientAccessWorkspace.svelte';
let { data } = $props();
</script>
<ClientAccessWorkspace {data} />
@@ -1,41 +0,0 @@
import { hasStoredAdminSession } from '$lib/session';
import { api } from '$lib/api';
export async function load({ fetch }) {
if (!hasStoredAdminSession()) {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: []
}
};
}
try {
const [clients, exportPreview] = await Promise.all([api.clientAccess(fetch), api.clientAccessExport(fetch)]);
return {
clients,
exportPreview
};
} catch {
return {
clients: [],
exportPreview: {
generated_at: '',
client_rows: [],
user_rows: [],
feature_rows: [],
permission_rows: [],
audit_rows: [],
clients: []
}
};
}
}
-8
View File
@@ -30,7 +30,6 @@ vi.mock('$lib/api', () => ({
vi.mock('$lib/session', () => sessionMocks); vi.mock('$lib/session', () => sessionMocks);
import { load as homeLoad } from './+page'; import { load as homeLoad } from './+page';
import { load as adminLoad } from './admin/+page';
import { load as mixesLoad } from './mixes/+page'; import { load as mixesLoad } from './mixes/+page';
import { load as mixNewLoad } from './mixes/new/+page'; import { load as mixNewLoad } from './mixes/new/+page';
import { load as mixDetailLoad } from './mixes/[id]/+page'; import { load as mixDetailLoad } from './mixes/[id]/+page';
@@ -153,11 +152,4 @@ describe('route loaders use the SvelteKit fetch argument', () => {
expect(apiMocks.scenarios).toHaveBeenCalledWith(fetcher); expect(apiMocks.scenarios).toHaveBeenCalledWith(fetcher);
}); });
it('passes fetch through the admin loader', async () => {
await adminLoad({ fetch: fetcher } as never);
expect(apiMocks.clientAccess).toHaveBeenCalledWith(fetcher);
expect(apiMocks.clientAccessExport).toHaveBeenCalledWith(fetcher);
});
}); });
+401
View File
@@ -0,0 +1,401 @@
<script lang="ts">
import { api } from '$lib/api';
import { clientSession } from '$lib/session';
import { canPlaceOrders } from '$lib/workspace-access';
import { toast } from '$lib/toast';
import type { CatalogueProduct, Order } from '$lib/types';
let { data } = $props();
// Catalogue is read-only from the loader; orders is a mutable copy that
// refreshOrders() reassigns, seeded from `data` via an effect.
const catalogue = $derived<CatalogueProduct[]>(data.catalogue ?? []);
let orders = $state<Order[]>([]);
$effect(() => {
orders = data.orders ?? [];
});
let category = $state<string>('all');
let search = $state('');
// Cart: product_id -> quantity
let cart = $state<Record<number, number>>({});
let editingOrderId = $state<number | null>(null);
let poNumber = $state('');
let deliveryNotes = $state('');
let requestedDate = $state('');
let fulfilment = $state<'delivery' | 'pickup'>('delivery');
let busy = $state(false);
const canOrder = $derived(canPlaceOrders($clientSession));
const categories = $derived(['all', ...Array.from(new Set(catalogue.map((p) => p.category)))]);
const productById = $derived(Object.fromEntries(catalogue.map((p) => [p.id, p])) as Record<number, CatalogueProduct>);
const filtered = $derived(
catalogue.filter((p) => {
if (category !== 'all' && p.category !== category) return false;
if (search) {
const n = search.toLowerCase();
return p.name.toLowerCase().includes(n) || p.sku.toLowerCase().includes(n);
}
return true;
})
);
const cartLines = $derived(
Object.entries(cart)
.map(([id, qty]) => ({ product: productById[Number(id)], qty }))
.filter((l) => l.product)
);
const cartCount = $derived(cartLines.length);
const cartSubtotal = $derived(
cartLines.reduce((sum, l) => sum + (l.product.price?.unit_price != null ? l.product.price.unit_price * l.qty : 0), 0)
);
const cartHasQuote = $derived(cartLines.some((l) => l.product.price?.requires_quote));
function money(value: number | null | undefined) {
if (value == null) return '—';
return new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }).format(value);
}
function statusLabel(s: string) {
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function addToCart(p: CatalogueProduct) {
const current = cart[p.id] ?? 0;
cart[p.id] = current ? current + 1 : Math.max(1, p.min_order_quantity);
}
function setQty(id: number, qty: number) {
if (qty <= 0) {
delete cart[id];
cart = { ...cart };
} else {
cart[id] = qty;
}
}
function clearCart() {
cart = {};
editingOrderId = null;
poNumber = '';
deliveryNotes = '';
requestedDate = '';
fulfilment = 'delivery';
}
function buildPayload() {
return {
lines: cartLines.map((l) => ({ product_id: l.product.id, quantity: l.qty })),
purchase_order_number: poNumber || null,
delivery_notes: deliveryNotes || null,
requested_delivery_date: requestedDate ? new Date(requestedDate).toISOString() : null,
fulfilment_method: fulfilment
};
}
async function refreshOrders() {
try {
orders = await api.ordering.orders();
} catch {
/* ignore */
}
}
async function saveDraft() {
if (!cartCount) return toast.error('Add at least one product first.');
busy = true;
try {
const payload = buildPayload();
const saved = editingOrderId
? await api.ordering.updateDraft(editingOrderId, payload)
: await api.ordering.createDraft(payload);
editingOrderId = saved.id;
toast.success(`Draft saved (${money(saved.subtotal_ex_gst)} ex GST).`);
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save draft.');
} finally {
busy = false;
}
}
async function submitOrder() {
if (!cartCount) return toast.error('Add at least one product first.');
busy = true;
try {
const payload = buildPayload();
const saved = editingOrderId
? await api.ordering.updateDraft(editingOrderId, payload)
: await api.ordering.createDraft(payload);
const submitted = await api.ordering.submit(saved.id, {});
toast.success(`Order ${submitted.order_number} submitted.`);
clearCart();
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not submit order.');
} finally {
busy = false;
}
}
async function editDraft(order: Order) {
cart = Object.fromEntries(order.lines.map((l) => [l.product_id, l.quantity]));
editingOrderId = order.id;
poNumber = order.purchase_order_number ?? '';
deliveryNotes = order.delivery_notes ?? '';
requestedDate = order.requested_delivery_date ? order.requested_delivery_date.slice(0, 10) : '';
fulfilment = (order.fulfilment_method as 'delivery' | 'pickup') ?? 'delivery';
toast.add('Draft loaded into the order builder.', 'info');
}
async function reorder(order: Order) {
busy = true;
try {
const draft = await api.ordering.reorder(order.id);
await editDraft(draft);
await refreshOrders();
toast.success('Reorder draft created.');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not reorder.');
} finally {
busy = false;
}
}
async function downloadPdf(order: Order) {
try {
const blob = await api.ordering.confirmationPdf(order.id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `order-${order.order_number ?? order.id}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not download PDF.');
}
}
</script>
<div class="ordering">
<header class="page-head">
<div>
<p class="eyebrow">Ordering Portal</p>
<h1>Order catalogue</h1>
<p class="sub">Your account-specific products and pricing. Prices exclude GST.</p>
</div>
</header>
<div class="layout">
<!-- Catalogue -->
<section class="catalogue surface-card">
<div class="toolbar">
<input class="search" placeholder="Search products or SKU…" bind:value={search} />
<div class="chips">
{#each categories as cat}
<button class="chip" class:active={category === cat} onclick={() => (category = cat)}>
{cat === 'all' ? 'All' : statusLabel(cat)}
</button>
{/each}
</div>
</div>
{#if !filtered.length}
<p class="empty">No products available.</p>
{:else}
<div class="grid">
{#each filtered as p (p.id)}
<article class="product">
<div class="product-top">
<span class="cat-tag">{statusLabel(p.category)}</span>
{#if p.stock_status !== 'in_stock'}<span class="stock">{statusLabel(p.stock_status)}</span>{/if}
</div>
<h3>{p.name}</h3>
<p class="sku">{p.sku} · {p.unit_of_measure}</p>
{#if p.description}<p class="desc">{p.description}</p>{/if}
<div class="price-row">
{#if p.price?.requires_quote}
<span class="quote-badge">Quote required</span>
{:else}
<strong>{money(p.price?.unit_price)}</strong>
<span class="price-label">{p.price?.label}</span>
{/if}
</div>
<div class="product-foot">
<span class="moq">Min {p.min_order_quantity}</span>
{#if canOrder}
<button class="add-btn" onclick={() => addToCart(p)} disabled={p.price?.requires_quote}>
{p.price?.requires_quote ? 'Quote' : 'Add'}
</button>
{/if}
</div>
</article>
{/each}
</div>
{/if}
</section>
<!-- Cart / order builder -->
<aside class="cart surface-card">
<div class="cart-head">
<h2>{editingOrderId ? 'Editing draft' : 'Order builder'}</h2>
{#if cartCount}<button class="link" onclick={clearCart}>Clear</button>{/if}
</div>
{#if !canOrder}
<p class="empty">Your role has view-only access. Contact an account owner to place orders.</p>
{:else if !cartCount}
<p class="empty">Add products from the catalogue to build an order.</p>
{:else}
<ul class="cart-lines">
{#each cartLines as line (line.product.id)}
<li>
<div class="cl-main">
<span class="cl-name">{line.product.name}</span>
<button class="link remove" onclick={() => setQty(line.product.id, 0)}>Remove</button>
</div>
<div class="cl-foot">
<input
class="qty"
type="number"
min={line.product.min_order_quantity}
step="1"
value={line.qty}
oninput={(e) => setQty(line.product.id, Number(e.currentTarget.value))}
/>
<span class="cl-unit">{line.product.price?.requires_quote ? 'Quote' : money(line.product.price?.unit_price)}</span>
<span class="cl-total">
{line.product.price?.unit_price != null ? money(line.product.price.unit_price * line.qty) : '—'}
</span>
</div>
</li>
{/each}
</ul>
<div class="totals">
<span>Subtotal (ex GST)</span>
<strong>{money(cartSubtotal)}</strong>
</div>
{#if cartHasQuote}<p class="quote-note">Some items need a quote — our team will confirm pricing.</p>{/if}
<div class="order-fields">
<label>PO number <input bind:value={poNumber} placeholder="Optional" /></label>
<label>Requested date <input type="date" bind:value={requestedDate} /></label>
<label class="full">
Fulfilment
<select bind:value={fulfilment}>
<option value="delivery">Delivery</option>
<option value="pickup">Pickup</option>
</select>
</label>
<label class="full">Delivery notes <textarea bind:value={deliveryNotes} rows="2" placeholder="Optional"></textarea></label>
</div>
<div class="cart-actions">
<button class="secondary" onclick={saveDraft} disabled={busy}>Save draft</button>
<button class="primary" onclick={submitOrder} disabled={busy}>Submit order</button>
</div>
{/if}
</aside>
</div>
<!-- Order history -->
<section class="history surface-card">
<h2>Your orders</h2>
{#if !orders.length}
<p class="empty">No orders yet.</p>
{:else}
<table>
<thead>
<tr><th>Order</th><th>Status</th><th>Items</th><th>Subtotal</th><th>Created</th><th></th></tr>
</thead>
<tbody>
{#each orders as o (o.id)}
<tr>
<td>{o.order_number ?? `Draft #${o.id}`}</td>
<td><span class="pill pill-{o.status}">{statusLabel(o.status)}</span></td>
<td>{o.lines.length}</td>
<td>{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
<td>{new Date(o.created_at).toLocaleDateString('en-AU')}</td>
<td class="row-actions">
{#if o.editable && canOrder}<button class="link" onclick={() => editDraft(o)}>Edit</button>{/if}
{#if canOrder}<button class="link" onclick={() => reorder(o)}>Reorder</button>{/if}
{#if o.order_number}<button class="link" onclick={() => downloadPdf(o)}>PDF</button>{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
</div>
<style>
.ordering { display: grid; gap: 1.25rem; }
h1 { margin: 0.2rem 0; font-size: 1.5rem; }
h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
h3 { margin: 0; font-size: 0.98rem; }
p { margin: 0; }
.eyebrow { color: var(--color-brand, #2f6f4f); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.sub { color: #64776b; font-size: 0.88rem; }
.surface-card { border: 1px solid rgba(34, 54, 45, 0.12); border-radius: 1rem; background: var(--surface, rgba(255,255,255,0.9)); padding: 1.1rem; }
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 22rem; gap: 1.25rem; align-items: start; }
.toolbar { display: grid; gap: 0.6rem; margin-bottom: 1rem; }
.search { width: 100%; padding: 0.6rem 0.75rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.7rem; }
.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.chip { padding: 0.3rem 0.7rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 999px; background: transparent; cursor: pointer; font-size: 0.8rem; }
.chip.active { background: var(--color-brand, #2f6f4f); color: #fff; border-color: transparent; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: 0.85rem; }
.product { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.85rem; border: 1px solid rgba(34,54,45,0.1); border-radius: 0.85rem; background: rgba(248,251,249,0.6); }
.product-top { display: flex; justify-content: space-between; gap: 0.5rem; }
.cat-tag { font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #6e8576; }
.stock { font-size: 0.66rem; font-weight: 700; color: #b07a1a; }
.sku { color: #7c8c82; font-size: 0.76rem; }
.desc { color: #64776b; font-size: 0.8rem; line-height: 1.3; }
.price-row { display: flex; align-items: baseline; gap: 0.5rem; margin-top: auto; }
.price-row strong { font-size: 1.05rem; }
.price-label { font-size: 0.7rem; color: #7c8c82; }
.quote-badge { font-size: 0.78rem; font-weight: 600; color: #b07a1a; }
.product-foot { display: flex; align-items: center; justify-content: space-between; }
.moq { font-size: 0.72rem; color: #7c8c82; }
.add-btn, .primary, .secondary { border-radius: 0.7rem; padding: 0.45rem 0.85rem; font-weight: 600; cursor: pointer; border: none; }
.add-btn { background: var(--color-brand, #2f6f4f); color: #fff; }
.add-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.cart-head { display: flex; justify-content: space-between; align-items: center; }
.cart-lines { list-style: none; margin: 0.5rem 0; padding: 0; display: grid; gap: 0.6rem; }
.cart-lines li { border-bottom: 1px solid rgba(34,54,45,0.08); padding-bottom: 0.5rem; }
.cl-main { display: flex; justify-content: space-between; gap: 0.5rem; }
.cl-name { font-size: 0.86rem; font-weight: 600; }
.cl-foot { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.35rem; }
.qty { width: 4rem; padding: 0.3rem 0.4rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.5rem; }
.cl-unit { font-size: 0.78rem; color: #7c8c82; }
.cl-total { margin-left: auto; font-weight: 600; font-size: 0.86rem; }
.totals { display: flex; justify-content: space-between; align-items: baseline; padding: 0.6rem 0; border-top: 1px solid rgba(34,54,45,0.12); }
.totals strong { font-size: 1.1rem; }
.quote-note { font-size: 0.76rem; color: #b07a1a; }
.order-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem; margin: 0.6rem 0; }
.order-fields label { display: grid; gap: 0.2rem; font-size: 0.74rem; color: #64776b; }
.order-fields .full { grid-column: 1 / -1; }
.order-fields input, .order-fields select, .order-fields textarea { padding: 0.45rem 0.5rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.5rem; font: inherit; }
.cart-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.primary { background: var(--color-brand, #2f6f4f); color: #fff; }
.secondary { background: rgba(34,54,45,0.08); color: #22362d; }
.primary:disabled, .secondary:disabled { opacity: 0.6; cursor: wait; }
.link { background: none; border: none; color: var(--color-brand, #2f6f4f); cursor: pointer; font-size: 0.8rem; padding: 0; }
.remove { color: #b33636; }
.empty { color: #7c8c82; font-size: 0.86rem; padding: 0.5rem 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.86rem; }
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid rgba(34,54,45,0.08); }
th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: #7c8c82; }
.row-actions { display: flex; gap: 0.6rem; }
.pill { padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-weight: 600; background: rgba(34,54,45,0.08); }
.pill-draft { background: #eee; color: #555; }
.pill-submitted, .pill-processing { background: #fdf0d5; color: #8a5a00; }
.pill-confirmed { background: #d8ece0; color: #1f6b46; }
.pill-dispatched, .pill-ready { background: #d5e4fd; color: #234e8a; }
.pill-completed { background: #d8ece0; color: #1f6b46; }
.pill-cancelled { background: #f7d5d5; color: #8a2323; }
@media (max-width: 960px) { .layout { grid-template-columns: 1fr; } }
</style>
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canManageOrdering, canOpenCustomerOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { catalogue: [], orders: [], canOrder: false };
}
const session = getStoredClientSession();
// Internal staff manage orders rather than place them — send them to the
// management console.
if (canManageOrdering(session)) {
throw redirect(307, '/ordering/manage');
}
if (!canOpenCustomerOrdering(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [catalogue, orders] = await Promise.all([api.ordering.catalogue(undefined, fetch), api.ordering.orders(undefined, fetch)]);
return { catalogue, orders, canOrder: true };
} catch {
return { catalogue: [], orders: [], canOrder: true };
}
}
@@ -0,0 +1,591 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import type {
CatalogueProduct,
CustomerPricing,
CustomerVisibilityRow,
Order,
OrderingCustomer,
OrderingCustomerUser,
OrderingNotificationSettings,
XeroStatus
} from '$lib/types';
let { data } = $props();
type Tab = 'orders' | 'products' | 'customers' | 'settings';
let tab = $state<Tab>('orders');
// Mutable local copies of the loader data (refresh helpers reassign these).
// Seeded from `data` via an effect so navigation re-syncs without the
// "only captures the initial value" warning.
let orders = $state<Order[]>([]);
let products = $state<CatalogueProduct[]>([]);
let customers = $state<OrderingCustomer[]>([]);
let xero = $state<XeroStatus | null>(null);
$effect(() => {
orders = data.orders ?? [];
products = data.products ?? [];
customers = data.customers ?? [];
xero = data.xero ?? null;
});
const STATUSES = [
'submitted',
'under_review',
'confirmed',
'sent_to_xero',
'in_production',
'ready_for_pickup',
'dispatched',
'completed',
'cancelled'
];
const CATEGORIES = ['grains', 'premixed', 'bags', 'bulk_loads', 'custom_blends', 'services'];
function money(v: number | null | undefined) {
if (v == null) return '—';
return new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }).format(v);
}
function label(s: string) {
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// --- Orders ---------------------------------------------------------------
let selectedOrder = $state<Order | null>(null);
let statusChoice = $state('');
async function openOrder(o: Order) {
try {
selectedOrder = await api.orderingAdmin.order(o.id);
statusChoice = '';
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load order.');
}
}
async function refreshOrders() {
try {
orders = await api.orderingAdmin.orders();
} catch {}
}
async function applyStatus() {
if (!selectedOrder || !statusChoice) return;
try {
selectedOrder = await api.orderingAdmin.updateStatus(selectedOrder.id, { to_status: statusChoice });
toast.success(`Status set to ${label(statusChoice)}.`);
statusChoice = '';
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Status change rejected.');
}
}
async function overrideLine(lineId: number, value: string) {
if (!selectedOrder || value === '') return;
try {
selectedOrder = await api.orderingAdmin.overrideLine(selectedOrder.id, lineId, { unit_price: Number(value), reason: 'Admin override' });
toast.success('Line price overridden.');
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Override failed.');
}
}
async function sendToXero() {
if (!selectedOrder) return;
try {
selectedOrder = await api.orderingAdmin.sendToXero(selectedOrder.id);
const r = selectedOrder.xero_result;
toast.success(`Xero ${r?.status}${r?.stubbed ? ' (stub)' : ''}: ${r?.xero_invoice_id ?? ''}`);
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Xero submission failed.');
}
}
async function reopenOrder() {
if (!selectedOrder) return;
try {
selectedOrder = await api.orderingAdmin.reopen(selectedOrder.id);
toast.success('Order reopened to draft.');
await refreshOrders();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not reopen.');
}
}
// --- Products -------------------------------------------------------------
let newProduct = $state<Record<string, any>>({ name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true });
async function refreshProducts() {
try {
products = await api.orderingAdmin.products();
} catch {}
}
async function createProduct() {
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
try {
await api.orderingAdmin.createProduct({ ...newProduct, base_price: newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price) });
toast.success('Product created.');
newProduct = { name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true };
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create product.');
}
}
async function toggleProductActive(p: CatalogueProduct) {
try {
await api.orderingAdmin.updateProduct(p.id, { active: !p.active });
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function saveProductPrice(p: CatalogueProduct, value: string) {
try {
await api.orderingAdmin.updateProduct(p.id, { base_price: value === '' ? null : Number(value) });
toast.success('Base price updated.');
await refreshProducts();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
// --- Customers ------------------------------------------------------------
let newCustomer = $state({ name: '', client_code: '' });
let selectedCustomer = $state<OrderingCustomer | null>(null);
let custUsers = $state<OrderingCustomerUser[]>([]);
let custPricing = $state<CustomerPricing | null>(null);
let custVisibility = $state<CustomerVisibilityRow[]>([]);
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
let discountInput = $state(0);
let newPrice = $state<Record<string, any>>({ product_id: '', unit_price: '', rule_type: 'fixed' });
async function refreshCustomers() {
try {
customers = await api.orderingAdmin.customers();
} catch {}
}
async function createCustomer() {
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
try {
await api.orderingAdmin.createCustomer(newCustomer);
toast.success('Customer created.');
newCustomer = { name: '', client_code: '' };
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
}
}
async function openCustomer(c: OrderingCustomer) {
selectedCustomer = c;
discountInput = c.discount_percent;
try {
[custUsers, custPricing, custVisibility] = await Promise.all([
api.orderingAdmin.customerUsers(c.id),
api.orderingAdmin.pricing(c.id),
api.orderingAdmin.visibility(c.id)
]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load customer.');
}
}
async function toggleCustomerStatus(c: OrderingCustomer) {
try {
const updated = await api.orderingAdmin.updateCustomer(c.id, { status: c.status === 'active' ? 'disabled' : 'active' });
toast.success(`Customer ${updated.status}.`);
await refreshCustomers();
if (selectedCustomer?.id === c.id) selectedCustomer = updated;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function addUser() {
if (!selectedCustomer) return;
if (!newUser.full_name || !newUser.email) return toast.error('Name and email required.');
try {
await api.orderingAdmin.createCustomerUser(selectedCustomer.id, newUser);
toast.success('User invited.');
newUser = { full_name: '', email: '', role: 'buyer' };
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not add user.');
}
}
async function toggleUserStatus(u: OrderingCustomerUser) {
if (!selectedCustomer) return;
try {
const next = u.status === 'suspended' ? 'active' : 'suspended';
await api.orderingAdmin.updateCustomerUser(selectedCustomer.id, u.id, { status: next });
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
async function saveDiscount() {
if (!selectedCustomer) return;
try {
custPricing = await api.orderingAdmin.setAssignment(selectedCustomer.id, { price_list_id: custPricing?.price_list_id ?? null, discount_percent: Number(discountInput) });
toast.success('Discount saved.');
await refreshCustomers();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save discount.');
}
}
async function addProductPrice() {
if (!selectedCustomer || !newPrice.product_id) return toast.error('Choose a product.');
try {
custPricing = await api.orderingAdmin.setProductPrice(selectedCustomer.id, {
product_id: Number(newPrice.product_id),
unit_price: newPrice.rule_type === 'quote' || newPrice.unit_price === '' ? null : Number(newPrice.unit_price),
rule_type: newPrice.rule_type
});
toast.success('Customer price saved.');
newPrice = { product_id: '', unit_price: '', rule_type: 'fixed' };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save price.');
}
}
async function removeProductPrice(productId: number) {
if (!selectedCustomer) return;
try {
await api.orderingAdmin.deleteProductPrice(selectedCustomer.id, productId);
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not remove price.');
}
}
async function toggleVisibility(row: CustomerVisibilityRow) {
if (!selectedCustomer) return;
try {
await api.orderingAdmin.setVisibility(selectedCustomer.id, { product_id: row.product_id, visible: !row.visible });
custVisibility = await api.orderingAdmin.visibility(selectedCustomer.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Update failed.');
}
}
function productName(id: number) {
return products.find((p) => p.id === id)?.name ?? `#${id}`;
}
// --- Settings -------------------------------------------------------------
let settings = $state<OrderingNotificationSettings | null>(null);
async function loadSettings() {
try {
settings = await api.orderingAdmin.notificationSettings();
xero = await api.orderingAdmin.xeroStatus();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not load settings.');
}
}
async function saveSettings() {
if (!settings) return;
try {
settings = await api.orderingAdmin.updateNotificationSettings(settings);
toast.success('Settings saved.');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Could not save settings.');
}
}
$effect(() => {
if (tab === 'settings' && !settings) loadSettings();
});
</script>
<div class="admin-ordering">
<header>
<p class="eyebrow">Ordering</p>
<h1>Order management</h1>
</header>
<nav class="tabs">
<button class:active={tab === 'orders'} onclick={() => (tab = 'orders')}>Orders</button>
<button class:active={tab === 'products'} onclick={() => (tab = 'products')}>Products</button>
<button class:active={tab === 'customers'} onclick={() => (tab = 'customers')}>Customers & Pricing</button>
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings & Xero</button>
</nav>
{#if tab === 'orders'}
<div class="split">
<section class="surface-card">
<h2>Order queue</h2>
{#if !orders.length}
<p class="empty">No submitted orders.</p>
{:else}
<table>
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Subtotal</th><th>Xero</th></tr></thead>
<tbody>
{#each orders as o (o.id)}
<tr class:selected={selectedOrder?.id === o.id} onclick={() => openOrder(o)}>
<td>{o.order_number ?? `#${o.id}`}</td>
<td>{o.customer_name}</td>
<td><span class="pill">{label(o.status)}</span></td>
<td>{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
<td>{o.xero_status ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
{#if selectedOrder}
<section class="surface-card detail">
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
<p class="muted">{selectedOrder.customer_name} · {label(selectedOrder.status)} · PO {selectedOrder.purchase_order_number ?? '—'}</p>
<table class="lines">
<thead><tr><th>Product</th><th>Qty</th><th>Unit</th><th>Override</th><th>Total</th></tr></thead>
<tbody>
{#each selectedOrder.lines as l (l.id)}
<tr>
<td>{l.product_name}</td>
<td>{l.quantity}</td>
<td>{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
<td>
<input class="ovr" type="number" step="0.01" placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'}
onchange={(e) => overrideLine(l.id, e.currentTarget.value)} />
</td>
<td>{money(l.line_total)}</td>
</tr>
{/each}
</tbody>
</table>
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
<div class="actions">
<select bind:value={statusChoice}>
<option value="">Change status…</option>
{#each STATUSES as s}<option value={s}>{label(s)}</option>{/each}
</select>
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
<button class="secondary" onclick={sendToXero}>Send to Xero</button>
<button class="secondary" onclick={reopenOrder}>Reopen</button>
</div>
{#if selectedOrder.status_history?.length}
<details class="history">
<summary>Status history ({selectedOrder.status_history.length})</summary>
<ul>
{#each selectedOrder.status_history as h}
<li>{label(h.from_status ?? 'new')}{label(h.to_status)} · {h.actor_name ?? h.actor_type} · {new Date(h.created_at).toLocaleString('en-AU')}</li>
{/each}
</ul>
</details>
{/if}
</section>
{/if}
</div>
{/if}
{#if tab === 'products'}
<section class="surface-card">
<h2>New product</h2>
<div class="form-grid">
<label>Name<input bind:value={newProduct.name} /></label>
<label>SKU<input bind:value={newProduct.sku} /></label>
<label>Category
<select bind:value={newProduct.category}>{#each CATEGORIES as c}<option value={c}>{label(c)}</option>{/each}</select>
</label>
<label>Unit of measure<input bind:value={newProduct.unit_of_measure} /></label>
<label>Min order qty<input type="number" bind:value={newProduct.min_order_quantity} /></label>
<label>Base price (ex GST)<input type="number" step="0.01" bind:value={newProduct.base_price} /></label>
<label class="check"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label>
<button class="primary" onclick={createProduct}>Create product</button>
</div>
</section>
<section class="surface-card">
<h2>Catalogue ({products.length})</h2>
<table>
<thead><tr><th>Name</th><th>SKU</th><th>Category</th><th>Base price</th><th>Active</th><th></th></tr></thead>
<tbody>
{#each products as p (p.id)}
<tr>
<td>{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</td>
<td>{p.sku}</td>
<td>{label(p.category)}</td>
<td><input class="inline" type="number" step="0.01" value={p.base_price ?? ''} onchange={(e) => saveProductPrice(p, e.currentTarget.value)} /></td>
<td>{p.active ? 'Yes' : 'No'}</td>
<td><button class="link" onclick={() => toggleProductActive(p)}>{p.active ? 'Disable' : 'Enable'}</button></td>
</tr>
{/each}
</tbody>
</table>
</section>
{/if}
{#if tab === 'customers'}
<div class="split">
<section class="surface-card">
<h2>New customer</h2>
<div class="form-row">
<input placeholder="Company name" bind:value={newCustomer.name} />
<input placeholder="Code (e.g. ACME)" bind:value={newCustomer.client_code} />
<button class="primary" onclick={createCustomer}>Create</button>
</div>
<h2 class="mt">Customers ({customers.length})</h2>
<table>
<thead><tr><th>Name</th><th>Code</th><th>Users</th><th>Status</th><th></th></tr></thead>
<tbody>
{#each customers as c (c.id)}
<tr class:selected={selectedCustomer?.id === c.id}>
<td><button class="link" onclick={() => openCustomer(c)}>{c.name}</button></td>
<td>{c.client_code}</td>
<td>{c.user_count}</td>
<td><span class="pill">{c.status}</span></td>
<td><button class="link" onclick={() => toggleCustomerStatus(c)}>{c.status === 'active' ? 'Disable' : 'Enable'}</button></td>
</tr>
{/each}
</tbody>
</table>
</section>
{#if selectedCustomer}
<section class="surface-card detail">
<h2>{selectedCustomer.name}</h2>
<h3>Users</h3>
<ul class="mini">
{#each custUsers as u (u.id)}
<li>{u.full_name} · {u.email} · {u.role} · {u.status}
<button class="link" onclick={() => toggleUserStatus(u)}>{u.status === 'suspended' ? 'Reactivate' : 'Suspend'}</button>
</li>
{/each}
</ul>
<div class="form-row">
<input placeholder="Full name" bind:value={newUser.full_name} />
<input placeholder="Email" bind:value={newUser.email} />
<select bind:value={newUser.role}>
<option value="owner">Owner</option><option value="buyer">Buyer</option>
<option value="accounts">Accounts</option><option value="viewer">Viewer</option>
</select>
<button class="secondary" onclick={addUser}>Invite</button>
</div>
<h3 class="mt">Pricing</h3>
<div class="form-row">
<label class="inline-label">Default discount %
<input type="number" step="0.5" bind:value={discountInput} />
</label>
<button class="secondary" onclick={saveDiscount}>Save discount</button>
</div>
{#if custPricing?.product_prices.length}
<ul class="mini">
{#each custPricing.product_prices as pp (pp.id)}
<li>{productName(pp.product_id)} · {pp.rule_type} · {pp.unit_price != null ? money(pp.unit_price) : 'quote'}
<button class="link" onclick={() => removeProductPrice(pp.product_id)}>Remove</button>
</li>
{/each}
</ul>
{/if}
<div class="form-row">
<select bind:value={newPrice.product_id}>
<option value="">Product…</option>
{#each products as p}<option value={p.id}>{p.name}</option>{/each}
</select>
<select bind:value={newPrice.rule_type}>
<option value="fixed">Fixed</option><option value="contract">Contract</option><option value="quote">Quote</option>
</select>
<input type="number" step="0.01" placeholder="Unit price" bind:value={newPrice.unit_price} disabled={newPrice.rule_type === 'quote'} />
<button class="secondary" onclick={addProductPrice}>Set price</button>
</div>
<h3 class="mt">Product visibility</h3>
<ul class="mini visibility">
{#each custVisibility as row (row.product_id)}
<li>
<label class="check"><input type="checkbox" checked={row.visible} onchange={() => toggleVisibility(row)} /> {row.name}</label>
</li>
{/each}
</ul>
</section>
{/if}
</div>
{/if}
{#if tab === 'settings'}
<div class="split">
<section class="surface-card">
<h2>Notification settings</h2>
{#if settings}
<div class="form-grid">
<label class="full">Internal recipients (comma separated)<input bind:value={settings.internal_recipients} /></label>
<label class="full">From email<input bind:value={settings.from_email} /></label>
<label class="check"><input type="checkbox" bind:checked={settings.send_customer_confirmation} /> Send customer confirmation</label>
<label class="check"><input type="checkbox" bind:checked={settings.require_po_number} /> Require PO number on submit</label>
<button class="primary" onclick={saveSettings}>Save settings</button>
</div>
{:else}
<p class="empty">Loading…</p>
{/if}
</section>
<section class="surface-card">
<h2>Xero integration</h2>
{#if xero}
<p class="muted">Mode: <strong>{xero.connection.mode}</strong> · {xero.connection.configured ? 'Configured' : 'Not configured (stub mode)'}</p>
{#if xero.connection.missing_env.length}
<p class="muted">Missing env: {xero.connection.missing_env.join(', ')}</p>
{/if}
<h3>Recent syncs</h3>
{#if !xero.recent_syncs.length}
<p class="empty">No Xero submissions yet.</p>
{:else}
<ul class="mini">
{#each xero.recent_syncs as s (s.id)}
<li>Order {s.order_id} · {s.status} · {s.xero_invoice_id ?? '—'} · {new Date(s.created_at).toLocaleString('en-AU')}</li>
{/each}
</ul>
{/if}
{:else}
<p class="empty">Loading…</p>
{/if}
</section>
</div>
{/if}
</div>
<style>
.admin-ordering { display: grid; gap: 1rem; }
h1 { margin: 0.15rem 0; font-size: 1.4rem; }
h2 { margin: 0 0 0.7rem; font-size: 1.05rem; }
h3 { margin: 0 0 0.4rem; font-size: 0.92rem; }
.mt { margin-top: 1rem; }
.eyebrow { color: #6e8576; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.muted { color: #64776b; font-size: 0.84rem; margin: 0 0 0.6rem; }
.surface-card { border: 1px solid rgba(34,54,45,0.12); border-radius: 1rem; background: rgba(255,255,255,0.92); padding: 1.1rem; }
.tabs { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.tabs button { padding: 0.45rem 0.9rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.7rem; background: transparent; cursor: pointer; font-weight: 600; font-size: 0.86rem; }
.tabs button.active { background: var(--color-brand, #2f6f4f); color: #fff; border-color: transparent; }
.split { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 0.45rem 0.55rem; border-bottom: 1px solid rgba(34,54,45,0.08); }
th { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: #7c8c82; }
tbody tr { cursor: pointer; }
tbody tr.selected { background: rgba(47,111,79,0.08); }
.pill { padding: 0.16rem 0.5rem; border-radius: 999px; font-size: 0.72rem; font-weight: 600; background: rgba(34,54,45,0.08); }
.tag { margin-left: 0.4rem; font-size: 0.64rem; padding: 0.05rem 0.35rem; border-radius: 999px; background: #fdf0d5; color: #8a5a00; }
.empty { color: #7c8c82; font-size: 0.85rem; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; align-items: end; }
.form-grid .full { grid-column: 1 / -1; }
.form-grid label, .form-row label { display: grid; gap: 0.2rem; font-size: 0.76rem; color: #64776b; }
.form-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.6rem; }
.form-row input, .form-row select, .form-grid input, .form-grid select { padding: 0.45rem 0.55rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; font: inherit; }
.check { display: flex; flex-direction: row; align-items: center; gap: 0.4rem; }
.inline-label { flex-direction: row; align-items: center; gap: 0.45rem; }
.inline { width: 6rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
.ovr { width: 5rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
.primary, .secondary { border-radius: 0.6rem; padding: 0.5rem 0.9rem; font-weight: 600; cursor: pointer; border: none; }
.primary { background: var(--color-brand, #2f6f4f); color: #fff; }
.secondary { background: rgba(34,54,45,0.08); color: #22362d; }
.link { background: none; border: none; color: var(--color-brand, #2f6f4f); cursor: pointer; font-size: 0.8rem; padding: 0; }
.lines td { font-size: 0.82rem; }
.detail-total { display: flex; justify-content: space-between; padding: 0.5rem 0; border-top: 1px solid rgba(34,54,45,0.12); margin: 0.4rem 0; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.actions select { padding: 0.45rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; }
.history { margin-top: 0.8rem; font-size: 0.8rem; }
.mini { list-style: none; margin: 0.3rem 0 0.6rem; padding: 0; display: grid; gap: 0.3rem; font-size: 0.82rem; }
.mini li { display: flex; gap: 0.5rem; align-items: center; justify-content: space-between; }
.visibility li { justify-content: flex-start; }
@media (max-width: 1000px) { .split, .form-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,30 @@
import { redirect } from '@sveltejs/kit';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
const EMPTY = { orders: [], products: [], customers: [], xero: null } as const;
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { ...EMPTY };
}
const session = getStoredClientSession();
if (!canManageOrdering(session)) {
// Customers (or anyone without manage rights) don't belong here.
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const [orders, products, customers, xero] = await Promise.all([
api.orderingAdmin.orders(undefined, fetch),
api.orderingAdmin.products(fetch),
api.orderingAdmin.customers(fetch),
api.orderingAdmin.xeroStatus(fetch)
]);
return { orders, products, customers, xero };
} catch {
return { ...EMPTY };
}
}