v0.1.19 - Throughput overview & responsive header

- Throughput: new "Throughput Overview" header with Gauge icon and brand-green
  badge icons on each card (Today, This week, 4-week average, Horse Mix, Grain Mix)
- Throughput: inline rolling-range selector (7d / 4w / 6w / 12w, default 4 weeks)
  driving the customer-mix cards; stats window widened to 12 weeks so switching
  range is a pure client-side re-filter
- Throughput: cards collapse to a single even 5-across row on laptop and up,
  with container-query value text that scales to each card's width
- Throughput: date logic pinned to Australian Eastern time (fixes the day-early
  date); This week subtitle shows the Mon-Sun date range
- Throughput: subtler tinted add-form; removed the inline-entry kicker and the
  "Open full form" link
- Topbar: fix cramped laptop header - action toggles no longer wrap above the
  user button; search drops to its own row earlier

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 10:01:10 +12:00
co-authored by Claude Opus 4.8
parent 4ff372d307
commit 2de82776cb
64 changed files with 6034 additions and 1134 deletions
+162 -3
View File
@@ -7,9 +7,10 @@ within the seller's ordering tenant.
from __future__ import annotations
import re
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
@@ -27,6 +28,7 @@ from app.models.ordering import (
PriceListItem,
PriceTier,
ProductCategory,
XeroContactLink,
XeroSyncLog,
)
from app.schemas.ordering import (
@@ -47,6 +49,7 @@ from app.schemas.ordering import (
PriceListItemUpsert,
ReopenOrderRequest,
VisibilityUpdate,
XeroContactLinkUpsert,
)
from app.services import ordering_service as svc
from app.services.client_access_service import (
@@ -54,7 +57,11 @@ from app.services.client_access_service import (
record_audit_event,
)
from app.services.order_notifications import get_or_create_settings
from app.services.xero_service import submit_order_to_xero, xero_status_snapshot
from app.services.xero_service import (
list_xero_contacts,
submit_order_to_xero,
xero_status_snapshot,
)
router = APIRouter(prefix="/api/ordering-admin", tags=["ordering-admin"])
@@ -122,11 +129,21 @@ def _serialize_tiers(db: Session, *, customer_product_price_id=None, price_list_
# --- Customers ---------------------------------------------------------------
def _xero_link_for(db: Session, customer_id: int) -> XeroContactLink | None:
return db.scalar(
select(XeroContactLink).where(
XeroContactLink.tenant_id == TENANT,
XeroContactLink.client_account_id == customer_id,
)
)
def _serialize_customer(db: Session, account: ClientAccount) -> dict:
users = db.scalars(select(ClientUser).where(ClientUser.client_account_id == account.id)).all()
assignment = db.scalar(
select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == account.id)
)
link = _xero_link_for(db, account.id)
return {
"id": account.id,
"name": account.name,
@@ -137,6 +154,8 @@ def _serialize_customer(db: Session, account: ClientAccount) -> dict:
"user_count": len(users),
"price_list_id": assignment.price_list_id if assignment else None,
"discount_percent": assignment.discount_percent if assignment else 0.0,
"xero_contact_id": link.xero_contact_id if link else None,
"xero_contact_name": link.xero_contact_name if link else None,
"created_at": account.created_at,
}
@@ -905,7 +924,10 @@ def send_order_to_xero(
if order.status not in {"confirmed", "in_production", "sent_to_xero"}:
raise HTTPException(status_code=409, detail="Only confirmed orders can be sent to Xero")
account = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
result = submit_order_to_xero(order, account)
link = _xero_link_for(db, order.client_account_id)
result = submit_order_to_xero(order, account, link)
if link is not None and result.status == "success":
link.last_synced_at = datetime.utcnow()
db.add(
XeroSyncLog(
@@ -985,8 +1007,19 @@ def get_xero_status(
recent = db.scalars(
select(XeroSyncLog).where(XeroSyncLog.tenant_id == TENANT).order_by(XeroSyncLog.created_at.desc()).limit(20)
).all()
total_customers = db.scalar(
select(func.count()).select_from(ClientAccount)
) or 0
linked_customers = db.scalar(
select(func.count()).select_from(XeroContactLink).where(XeroContactLink.tenant_id == TENANT)
) or 0
return {
"connection": xero_status_snapshot(),
"contact_links": {
"linked": linked_customers,
"total": total_customers,
"unlinked": max(total_customers - linked_customers, 0),
},
"recent_syncs": [
{
"id": log.id,
@@ -999,3 +1032,129 @@ def get_xero_status(
for log in recent
],
}
# --- Xero contact mapping ----------------------------------------------------
@router.get("/xero/contacts")
def list_available_xero_contacts(
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
"""The Xero contacts available to link a customer against (stub or live)."""
contacts, stubbed = list_xero_contacts()
return {"contacts": [c.as_dict() for c in contacts], "stubbed": stubbed}
@router.get("/xero/contact-links")
def list_xero_contact_links(
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
"""Every customer with its current Xero link and a suggested match.
The suggestion is a best-effort name match against the available contacts so
the operator can confirm rather than hunt through a dropdown.
"""
contacts, _ = list_xero_contacts()
by_name = {c.name.strip().lower(): c for c in contacts}
accounts = db.scalars(select(ClientAccount).order_by(ClientAccount.name)).all()
rows = []
for account in accounts:
link = _xero_link_for(db, account.id)
suggestion = None if link else by_name.get(account.name.strip().lower())
rows.append(
{
"customer_id": account.id,
"customer_name": account.name,
"client_code": account.client_code,
"linked": link is not None,
"xero_contact_id": link.xero_contact_id if link else None,
"xero_contact_name": link.xero_contact_name if link else None,
"xero_contact_email": link.xero_contact_email if link else None,
"last_synced_at": link.last_synced_at if link else None,
"suggested_contact_id": suggestion.contact_id if suggestion else None,
}
)
return rows
@router.put("/customers/{customer_id}/xero-link")
def link_customer_to_xero(
customer_id: int,
payload: XeroContactLinkUpsert,
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
account = _customer_or_404(db, customer_id)
# Default the cached name/email from the known contact list when the caller
# only sends an id, so the mapping reads nicely without a live round-trip.
name = payload.xero_contact_name
email = payload.xero_contact_email
if name is None or email is None:
contacts, _ = list_xero_contacts()
match = next((c for c in contacts if c.contact_id == payload.xero_contact_id), None)
if match is not None:
name = name or match.name
email = email or match.email
link = _xero_link_for(db, customer_id)
if link is None:
link = XeroContactLink(
tenant_id=TENANT,
client_account_id=customer_id,
xero_contact_id=payload.xero_contact_id,
xero_contact_name=name,
xero_contact_email=email,
)
db.add(link)
else:
link.xero_contact_id = payload.xero_contact_id
link.xero_contact_name = name
link.xero_contact_email = email
record_audit_event(
db,
tenant_id=account.tenant_id,
client_account_id=account.id,
action="xero.contact_linked",
target_type="xero_contact_link",
target_id=account.id,
module_key="ordering",
summary=f"{account.name} linked to Xero contact {name or payload.xero_contact_id}.",
**_actor(session),
)
db.commit()
return {
"customer_id": customer_id,
"linked": True,
"xero_contact_id": link.xero_contact_id,
"xero_contact_name": link.xero_contact_name,
"xero_contact_email": link.xero_contact_email,
}
@router.delete("/customers/{customer_id}/xero-link", status_code=status.HTTP_204_NO_CONTENT)
def unlink_customer_from_xero(
customer_id: int,
session: AuthSession = Depends(require_ordering_admin_session),
db: Session = Depends(get_db),
):
account = _customer_or_404(db, customer_id)
link = _xero_link_for(db, customer_id)
if link is not None:
db.delete(link)
record_audit_event(
db,
tenant_id=account.tenant_id,
client_account_id=account.id,
action="xero.contact_unlinked",
target_type="xero_contact_link",
target_id=account.id,
module_key="ordering",
summary=f"{account.name} unlinked from Xero contact.",
**_actor(session),
)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)