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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user