v0.1.14 - b2b portal
This commit is contained in:
@@ -184,6 +184,29 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
|
||||
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(
|
||||
session: AuthSession = Depends(get_auth_session),
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -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
@@ -53,6 +53,9 @@ _PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
|
||||
"edit_mixes": ("mix_master", "edit"),
|
||||
"view_throughput": ("operations_throughput", "view"),
|
||||
"edit_throughput": ("operations_throughput", "edit"),
|
||||
"view_ordering": ("ordering", "view"),
|
||||
"edit_ordering": ("ordering", "edit"),
|
||||
"manage_ordering": ("ordering", "manage"),
|
||||
"view_scenarios": ("scenarios", "view"),
|
||||
"edit_scenarios": ("scenarios", "edit"),
|
||||
"manage_client_access": ("client_access", "manage"),
|
||||
|
||||
@@ -42,6 +42,21 @@ TENANT_TABLES = {
|
||||
"freight_cost_rules": None,
|
||||
"throughput_products": 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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.mix_calculator import router as mix_calculator_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.product_costing import router as product_costing_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(scenarios_router)
|
||||
app.include_router(throughput_router)
|
||||
app.include_router(ordering_router)
|
||||
app.include_router(ordering_admin_router)
|
||||
app.include_router(powerbi_router)
|
||||
|
||||
|
||||
|
||||
@@ -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.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
|
||||
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_costing import (
|
||||
ProductCostBagInput,
|
||||
@@ -17,14 +33,28 @@ from app.models.scenario import CostingResult, Scenario
|
||||
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||
|
||||
__all__ = [
|
||||
"CatalogueProduct",
|
||||
"ClientAccount",
|
||||
"ClientAccessAuditEvent",
|
||||
"ClientFeatureAccess",
|
||||
"ClientUser",
|
||||
"ClientUserModulePermission",
|
||||
"CostingResult",
|
||||
"CustomerPriceAssignment",
|
||||
"CustomerProductPrice",
|
||||
"CustomerProductVisibility",
|
||||
"FreightCostRule",
|
||||
"Mix",
|
||||
"NotificationSetting",
|
||||
"Order",
|
||||
"OrderAttachment",
|
||||
"OrderLine",
|
||||
"OrderStatusHistory",
|
||||
"PriceList",
|
||||
"PriceListItem",
|
||||
"PriceTier",
|
||||
"ProductCategory",
|
||||
"XeroSyncLog",
|
||||
"MixCalculatorSession",
|
||||
"MixCalculatorSessionLine",
|
||||
"MixIngredient",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -1285,6 +1285,23 @@ def seed_startup_basics():
|
||||
logger.info("Product costing module seeded: %s", product_costing_report)
|
||||
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():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -31,6 +31,9 @@ PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
|
||||
("edit_mixes", "Create and edit mix master recipes"),
|
||||
("view_throughput", "View operations throughput"),
|
||||
("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"),
|
||||
("edit_scenarios", "Create, run, approve, and reject scenarios"),
|
||||
("manage_client_access", "Manage client accounts, users, feature access, and exports"),
|
||||
@@ -58,6 +61,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
||||
"edit_mixes",
|
||||
"view_throughput",
|
||||
"edit_throughput",
|
||||
"view_ordering",
|
||||
"edit_ordering",
|
||||
"manage_ordering",
|
||||
"view_scenarios",
|
||||
"edit_scenarios",
|
||||
"manage_client_access",
|
||||
@@ -93,6 +99,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
||||
"edit_mixes",
|
||||
"view_throughput",
|
||||
"edit_throughput",
|
||||
"view_ordering",
|
||||
"edit_ordering",
|
||||
"manage_ordering",
|
||||
],
|
||||
},
|
||||
"lean": {
|
||||
@@ -110,6 +119,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
||||
"edit_mixes",
|
||||
"view_throughput",
|
||||
"edit_throughput",
|
||||
"view_ordering",
|
||||
"edit_ordering",
|
||||
"manage_ordering",
|
||||
"view_scenarios",
|
||||
"edit_scenarios",
|
||||
"manage_client_access",
|
||||
|
||||
@@ -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
|
||||
@@ -21,6 +21,7 @@ MODULE_CATALOG = (
|
||||
("products", "Products", "pricing", "Review finished product pricing"),
|
||||
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
|
||||
("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"),
|
||||
("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:
|
||||
normalized = role.strip().lower()
|
||||
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 module_key in {"mix_calculator", "operations_throughput"}:
|
||||
if module_key in {"mix_calculator", "operations_throughput", "ordering"}:
|
||||
return "manage"
|
||||
return "edit" if module_key != "client_access" else "none"
|
||||
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":
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "data-entry-app-backend"
|
||||
version = "0.1.12"
|
||||
version = "0.1.14"
|
||||
description = "Costing platform MVP backend"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,15 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<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 () {
|
||||
try {
|
||||
var pref = localStorage.getItem('theme');
|
||||
var dark =
|
||||
pref === 'dark' ||
|
||||
((!pref || pref === 'system') &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = pref === 'dark' ? 'dark' : 'light';
|
||||
} catch (e) {
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
}
|
||||
|
||||
+95
-1
@@ -29,6 +29,15 @@ import type {
|
||||
RawMaterial,
|
||||
RawMaterialCreateInput,
|
||||
RawMaterialPriceCreateInput,
|
||||
CatalogueProduct,
|
||||
CustomerPricing,
|
||||
CustomerVisibilityRow,
|
||||
DraftOrderInput,
|
||||
Order,
|
||||
OrderingCustomer,
|
||||
OrderingCustomerUser,
|
||||
OrderingNotificationSettings,
|
||||
XeroStatus,
|
||||
Scenario,
|
||||
ThroughputEntry,
|
||||
ThroughputEntryCreateInput,
|
||||
@@ -486,5 +495,90 @@ export const api = {
|
||||
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
|
||||
method: 'PATCH',
|
||||
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)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 ClientTopbar from '$lib/components/navigation/ClientTopbar.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 { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
@@ -17,6 +20,8 @@
|
||||
canOpenEditor as sessionCanOpenEditor,
|
||||
canOpenMixCalculator as sessionCanOpenMixCalculator,
|
||||
canOpenMixMaster as sessionCanOpenMixMaster,
|
||||
canOpenCustomerOrdering as sessionCanOpenCustomerOrdering,
|
||||
canManageOrdering as sessionCanManageOrdering,
|
||||
canOpenProductCosting as sessionCanOpenProductCosting,
|
||||
canOpenReporting as sessionCanOpenReporting,
|
||||
canOpenSettings as sessionCanOpenSettings,
|
||||
@@ -36,6 +41,7 @@
|
||||
footerLinks,
|
||||
matchesRoute,
|
||||
mixCalculatorItem,
|
||||
orderingItem,
|
||||
pageTitle,
|
||||
productCostingItem,
|
||||
reportingItem,
|
||||
@@ -65,6 +71,10 @@
|
||||
let userMenuOpen = $state(false);
|
||||
let navOpen = $state(false);
|
||||
let showBottomNav = $state(false);
|
||||
let whatsNewOpen = $state(false);
|
||||
// The user identity we've already run the "what's new" check for this mount,
|
||||
// so the dialog is evaluated once per login rather than on every navigation.
|
||||
let whatsNewCheckedFor = $state<string | null>(null);
|
||||
let isRestoringSession = $state(false);
|
||||
let restoredSessionKey = $state<string | null>(null);
|
||||
let seededSearchItems = $state<SearchItem[]>([]);
|
||||
@@ -102,6 +112,17 @@
|
||||
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
|
||||
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
|
||||
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
|
||||
// Ordering serves two audiences: internal staff get the management console
|
||||
// (/ordering/manage), customers get the catalogue (/ordering).
|
||||
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
|
||||
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
|
||||
const visibleOrderingItem = $derived(
|
||||
canManageOrdering
|
||||
? { ...orderingItem, href: '/ordering/manage', label: 'Order Management', shortLabel: 'OM' }
|
||||
: canOpenCustomerOrdering
|
||||
? orderingItem
|
||||
: null
|
||||
);
|
||||
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
|
||||
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
|
||||
// Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the
|
||||
@@ -117,6 +138,7 @@
|
||||
...visibleWorkingDocumentItems
|
||||
],
|
||||
throughput: visibleThroughputItem,
|
||||
ordering: visibleOrderingItem,
|
||||
reporting: visibleReportingItem
|
||||
})
|
||||
);
|
||||
@@ -331,6 +353,33 @@
|
||||
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(() => {
|
||||
syncViewport();
|
||||
|
||||
@@ -639,6 +688,10 @@
|
||||
|
||||
{/if}
|
||||
|
||||
{#if $clientSession && whatsNewOpen && currentChangelog}
|
||||
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
|
||||
{/if}
|
||||
|
||||
{#if $clientSession && paletteOpen}
|
||||
<div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}>
|
||||
<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">© {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;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0.6rem;
|
||||
background: #fff;
|
||||
background: var(--color-input-bg);
|
||||
color: var(--text);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
|
||||
@@ -203,8 +203,9 @@
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
/* Light monochrome rail with a dark selected pill. The rail keeps its own palette
|
||||
via the --sidebar-* tokens, independent of the content theme. */
|
||||
/* Monochrome rail with a blue selected pill. Colours come from the --sidebar-*
|
||||
tokens, which are overridden in dark mode (see theme.css) so the rail themes
|
||||
alongside the content instead of staying a bright light strip. */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
TrendingUp
|
||||
} from 'lucide-svelte';
|
||||
import type { ComponentType } from 'svelte';
|
||||
@@ -112,6 +113,14 @@ export const throughputItem: NavItem = {
|
||||
badge: 'test'
|
||||
};
|
||||
|
||||
export const orderingItem: NavItem = {
|
||||
href: '/ordering',
|
||||
label: 'Ordering',
|
||||
shortLabel: 'OR',
|
||||
icon: ShoppingCart,
|
||||
moduleKey: 'ordering'
|
||||
};
|
||||
|
||||
export const workingDocumentItems: NavItem[] = [
|
||||
// Mix Master remains available through the existing route and access logic,
|
||||
// but is temporarily hidden from the sidebar.
|
||||
@@ -210,6 +219,7 @@ export function buildClientNavEntries(visible: {
|
||||
dashboard?: NavItem | null;
|
||||
costing: NavItem[];
|
||||
throughput?: NavItem | null;
|
||||
ordering?: NavItem | null;
|
||||
reporting?: NavItem | null;
|
||||
}): 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) {
|
||||
entries.push({ kind: 'item', item: visible.throughput });
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
--color-surface-hover: oklch(0.955 0.004 240);
|
||||
--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 ────────────────────────────────────────────── */
|
||||
--color-border: oklch(0.92 0.005 240);
|
||||
--color-divider: oklch(0.94 0.004 240);
|
||||
@@ -117,10 +120,28 @@
|
||||
--color-surface-hover: oklch(0.27 0.006 240);
|
||||
--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 ────────────────────────────────────────────── */
|
||||
--color-border: oklch(0.32 0.006 240);
|
||||
--color-divider: oklch(0.28 0.005 240);
|
||||
|
||||
/* ── Sidebar: dark rail tuned to the content theme so it stops
|
||||
rendering as a bright light strip in dark mode. Active item keeps
|
||||
the blue pill from light mode. ── */
|
||||
--sidebar-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) ─────────────────────────────────────── */
|
||||
--color-text-primary: oklch(0.96 0.003 240);
|
||||
--color-text-secondary: oklch(0.78 0.006 240);
|
||||
@@ -448,7 +469,7 @@ a {
|
||||
padding: 0.82rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--panel-soft);
|
||||
background: var(--color-input-bg);
|
||||
color: var(--color-text-primary);
|
||||
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);
|
||||
|
||||
@@ -6,18 +6,17 @@ export type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
const STORAGE_KEY = 'theme';
|
||||
|
||||
function systemTheme(): ResolvedTheme {
|
||||
return browser && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// Dark mode is strictly opt-in. Light is the default and the OS colour scheme is
|
||||
// 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 {
|
||||
return pref === 'system' ? systemTheme() : pref;
|
||||
return pref === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function readPreference(): ThemePreference {
|
||||
if (!browser) return 'system';
|
||||
if (!browser) return 'light';
|
||||
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) {
|
||||
@@ -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());
|
||||
|
||||
/** The theme actually painted right now ('system' collapsed to light/dark). */
|
||||
/** The theme actually painted right now. */
|
||||
export const resolvedTheme = writable<ResolvedTheme>(resolve(readPreference()));
|
||||
|
||||
if (browser) {
|
||||
@@ -39,15 +38,6 @@ if (browser) {
|
||||
resolvedTheme.set(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. */
|
||||
|
||||
@@ -637,3 +637,168 @@ export type 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;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,52 @@ export function canOpenReporting(session: AppSession | null | undefined) {
|
||||
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) {
|
||||
if (!session) {
|
||||
return false;
|
||||
@@ -167,10 +213,20 @@ export const routeAccessRules: RouteAccessRule[] = [
|
||||
path: '/throughput',
|
||||
roles: ['admin', 'operations', 'full', 'client'],
|
||||
matches: (pathname) => hasPathPrefix(pathname, '/throughput')
|
||||
},
|
||||
{
|
||||
path: '/ordering',
|
||||
roles: ['admin', 'full', 'client'],
|
||||
matches: (pathname) => hasPathPrefix(pathname, '/ordering')
|
||||
}
|
||||
];
|
||||
|
||||
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);
|
||||
|
||||
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('/client-access')) return canOpenClientAccess(session);
|
||||
if (pathname.startsWith('/throughput')) return canOpenThroughput(session);
|
||||
if (pathname.startsWith('/ordering')) return canOpenOrdering(session);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,19 @@
|
||||
import '$lib/theme';
|
||||
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import AdminShell from '$lib/components/AdminShell.svelte';
|
||||
import ClientShell from '$lib/components/ClientShell.svelte';
|
||||
import CustomerPortalShell from '$lib/components/CustomerPortalShell.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import { clientSession } from '$lib/session';
|
||||
import { isCustomerPortalSession } from '$lib/workspace-access';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
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'));
|
||||
// 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 navTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -41,10 +45,10 @@
|
||||
|
||||
{#if isPrintableRoute}
|
||||
{@render children()}
|
||||
{:else if isAdminRoute}
|
||||
<AdminShell>
|
||||
{:else if isCustomerPortal}
|
||||
<CustomerPortalShell>
|
||||
{@render children()}
|
||||
</AdminShell>
|
||||
</CustomerPortalShell>
|
||||
{:else}
|
||||
<ClientShell>
|
||||
{@render children()}
|
||||
|
||||
@@ -63,9 +63,16 @@
|
||||
|
||||
try {
|
||||
// Authenticates against the internal Hunter Stock Feeds role/permission
|
||||
// system. The response is shape-compatible with the legacy client
|
||||
// session, so the rest of the app continues to work unchanged.
|
||||
const session = await api.internalLogin(email, password);
|
||||
// system first. If that fails (e.g. a B2B ordering-portal customer, who
|
||||
// lives in the ClientUser table and signs in with the shared client
|
||||
// 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);
|
||||
postLoginRedirecting = targetHref !== '/';
|
||||
clientSession.set(session);
|
||||
|
||||
@@ -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>
|
||||
@@ -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: []
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ vi.mock('$lib/api', () => ({
|
||||
vi.mock('$lib/session', () => sessionMocks);
|
||||
|
||||
import { load as homeLoad } from './+page';
|
||||
import { load as adminLoad } from './admin/+page';
|
||||
import { load as mixesLoad } from './mixes/+page';
|
||||
import { load as mixNewLoad } from './mixes/new/+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);
|
||||
});
|
||||
|
||||
it('passes fetch through the admin loader', async () => {
|
||||
await adminLoad({ fetch: fetcher } as never);
|
||||
|
||||
expect(apiMocks.clientAccess).toHaveBeenCalledWith(fetcher);
|
||||
expect(apiMocks.clientAccessExport).toHaveBeenCalledWith(fetcher);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user