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:
@@ -23,7 +23,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.client_access import ClientAccount
|
||||
from app.models.ordering import Order
|
||||
from app.models.ordering import Order, XeroContactLink
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -57,11 +57,75 @@ class XeroSubmissionResult:
|
||||
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."""
|
||||
@dataclass
|
||||
class XeroContact:
|
||||
"""A Xero contact available to link a customer against."""
|
||||
|
||||
contact_id: str
|
||||
name: str
|
||||
email: str | None = None
|
||||
status: str = "ACTIVE"
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"contact_id": self.contact_id,
|
||||
"name": self.name,
|
||||
"email": self.email,
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
|
||||
# Deterministic sample contacts used while running in stub mode (no Xero
|
||||
# credentials). They stand in for "what's in Xero" so the customer→contact
|
||||
# mapping UI is usable before the live API is wired. Ids mimic Xero GUIDs.
|
||||
_STUB_CONTACTS: tuple[XeroContact, ...] = (
|
||||
XeroContact("STUB-CON-0001", "Hunter Premium Produce", "accounts@hunterpremium.example", "ACTIVE"),
|
||||
XeroContact("STUB-CON-0002", "Mayreef Pty Ltd", "ap@mayreef.example", "ACTIVE"),
|
||||
XeroContact("STUB-CON-0003", "Ian McKay Stock Feeds", "ian@mckayfeeds.example", "ACTIVE"),
|
||||
XeroContact("STUB-CON-0004", "Peckish Bird Foods", "orders@peckish.example", "ACTIVE"),
|
||||
XeroContact("STUB-CON-0005", "Hay & Straw Co", "info@hayandstraw.example", "ACTIVE"),
|
||||
XeroContact("STUB-CON-0006", "PHF Horse Mixes", "accounts@phfhorse.example", "ACTIVE"),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_contacts_from_api(config: XeroConfig) -> list[XeroContact]:
|
||||
"""Live contact fetch. Stubbed until credentials/endpoints are wired.
|
||||
|
||||
TODO (go-live): GET ``{config.base_url}/Contacts`` with the
|
||||
``Xero-tenant-id`` header, page through ``Contacts[]`` and map each onto a
|
||||
:class:`XeroContact` (``ContactID``/``Name``/``EmailAddress``/``ContactStatus``).
|
||||
"""
|
||||
raise NotImplementedError("Live Xero contact fetch is not implemented yet.")
|
||||
|
||||
|
||||
def list_xero_contacts(config: XeroConfig | None = None) -> tuple[list[XeroContact], bool]:
|
||||
"""Return the Xero contacts available for linking and whether they're stubbed.
|
||||
|
||||
Never raises — on a live-mode error it returns an empty list so the mapping
|
||||
console still renders.
|
||||
"""
|
||||
config = config or XeroConfig.from_env()
|
||||
if not config.configured:
|
||||
return list(_STUB_CONTACTS), True
|
||||
try:
|
||||
return _fetch_contacts_from_api(config), False
|
||||
except Exception: # pragma: no cover - defensive: never break the request path
|
||||
return [], False
|
||||
|
||||
|
||||
def map_customer_to_contact(customer: ClientAccount, link: XeroContactLink | None = None) -> dict:
|
||||
"""Map a customer account onto a Xero contact payload.
|
||||
|
||||
When the customer has been linked to a Xero contact we send the real
|
||||
``ContactID`` so Xero attaches the invoice to the existing contact. Without a
|
||||
link we fall back to keying on the client code (Xero will match-or-create).
|
||||
"""
|
||||
if link is not None and link.xero_contact_id:
|
||||
return {
|
||||
"ContactID": link.xero_contact_id,
|
||||
"Name": link.xero_contact_name or customer.name,
|
||||
}
|
||||
return {
|
||||
# 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,
|
||||
}
|
||||
@@ -76,7 +140,9 @@ def map_product_to_item_code(product_sku: str) -> str:
|
||||
return product_sku
|
||||
|
||||
|
||||
def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
|
||||
def build_invoice_payload(
|
||||
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
|
||||
) -> dict:
|
||||
"""Build the Xero draft-invoice payload for a confirmed order."""
|
||||
line_items = []
|
||||
for line in order.lines:
|
||||
@@ -98,7 +164,7 @@ def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
|
||||
return {
|
||||
"Type": "ACCREC",
|
||||
"Status": "DRAFT",
|
||||
"Contact": map_customer_to_contact(customer),
|
||||
"Contact": map_customer_to_contact(customer, link),
|
||||
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
|
||||
"LineAmountTypes": "Exclusive",
|
||||
"LineItems": line_items,
|
||||
@@ -127,14 +193,17 @@ def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResu
|
||||
)
|
||||
|
||||
|
||||
def submit_order_to_xero(order: Order, customer: ClientAccount) -> XeroSubmissionResult:
|
||||
def submit_order_to_xero(
|
||||
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
|
||||
) -> XeroSubmissionResult:
|
||||
"""Submit a confirmed order to Xero, or stub it when unconfigured.
|
||||
|
||||
Never raises — failures are returned as ``status="failed"`` results so the
|
||||
order lifecycle can record the attempt and continue.
|
||||
Pass ``link`` to invoice against the customer's mapped Xero contact. 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)
|
||||
payload = build_invoice_payload(order, customer, link)
|
||||
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
|
||||
|
||||
if not config.configured:
|
||||
|
||||
Reference in New Issue
Block a user