diff --git a/backend/app/api/editor.py b/backend/app/api/editor.py index 9f03e2a..e83c56c 100644 --- a/backend/app/api/editor.py +++ b/backend/app/api/editor.py @@ -1,14 +1,21 @@ from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import func, or_, select +from sqlalchemy import case, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload, selectinload from app.api.deps import AuthSession, get_auth_session from app.db.session import get_db -from app.models.mix import Mix +from app.models.mix import Mix, MixIngredient from app.models.product import Product, ProductIngredient from app.models.raw_material import RawMaterial from app.schemas.editor import ( + EditorIngredientCreate, + EditorIngredientRow, + EditorIngredientUpdate, + EditorMixFormulaRead, + EditorMixIngredientCreate, + EditorMixIngredientUpdate, + EditorMixRow, EditorMixUpdate, EditorProductFormulaRead, EditorProductIngredientCreate, @@ -17,6 +24,7 @@ from app.schemas.editor import ( EditorProductUpdate, ) from app.services.client_access_service import has_access_level +from app.services.costing_engine import calculate_raw_material_cost, get_active_price router = APIRouter(prefix="/api/editor", tags=["editor"]) @@ -63,6 +71,65 @@ def _serialize_product_formula(product: Product) -> dict: } +def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict: + return { + "id": mix.id, + "tenant_id": mix.tenant_id, + "client_name": mix.client_name, + "name": mix.name, + "visible": visible_count > 0, + "product_count": product_count, + "visible_product_count": visible_count, + "notes": mix.notes, + } + + +def _mix_product_counts(db: Session, tenant_id: str) -> dict[int, tuple[int, int]]: + """Per-mix (total products, visible products) used to drive the Status column.""" + rows = db.execute( + select( + Product.mix_id, + func.count(), + func.sum(case((Product.visible, 1), else_=0)), + ) + .where(Product.tenant_id == tenant_id) + .group_by(Product.mix_id) + ).all() + return {mix_id: (int(total), int(visible or 0)) for mix_id, total, visible in rows} + + +def _serialize_mix_formula(mix: Mix) -> dict: + ingredients = [ + { + "id": ingredient.id, + "raw_material_id": ingredient.raw_material_id, + "raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}", + "quantity_kg": ingredient.quantity_kg, + "notes": ingredient.notes, + } + for ingredient in sorted( + mix.ingredients, + key=lambda item: item.raw_material.name if item.raw_material else "", + ) + ] + return { + "id": mix.id, + "tenant_id": mix.tenant_id, + "client_name": mix.client_name, + "name": mix.name, + "ingredients": ingredients, + "total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4), + } + + +def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None: + return db.scalar( + select(Mix) + .where(Mix.id == mix_id, Mix.tenant_id == tenant_id) + .options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material)) + ) + + def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None: return db.scalar( select(Product) @@ -156,7 +223,34 @@ def update_editor_product( return _serialize_row(product) -@router.patch("/mixes/{mix_id}", response_model=list[EditorProductRow]) +@router.get("/mixes", response_model=list[EditorMixRow]) +def list_editor_mixes( + q: str | None = Query(default=None, max_length=255), + client_name: str | None = Query(default=None, max_length=255), + limit: int = Query(default=500, ge=1, le=1000), + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + statement = select(Mix).where(Mix.tenant_id == session.tenant_id) + + if client_name: + statement = statement.where(Mix.client_name == client_name) + + if q: + term = f"%{q.strip()}%" + statement = statement.where(or_(Mix.client_name.ilike(term), Mix.name.ilike(term))) + + statement = statement.order_by(Mix.client_name, Mix.name, Mix.id).limit(limit) + + counts = _mix_product_counts(db, session.tenant_id or "") + mixes = db.scalars(statement).all() + return [ + _serialize_mix_row(mix, visible_count=counts.get(mix.id, (0, 0))[1], product_count=counts.get(mix.id, (0, 0))[0]) + for mix in mixes + ] + + +@router.patch("/mixes/{mix_id}", response_model=EditorMixRow) def update_editor_mix( mix_id: int, payload: EditorMixUpdate, @@ -167,18 +261,120 @@ def update_editor_mix( if mix is None: raise HTTPException(status_code=404, detail="Mix not found") - for field, value in payload.model_dump(exclude_unset=True).items(): + updates = payload.model_dump(exclude_unset=True) + # `visible` is a virtual field: it fans out to the visibility of every product + # under the mix rather than mapping to a mix column. + visible = updates.pop("visible", None) + for field, value in updates.items(): setattr(mix, field, value) + if visible is not None: + for product in db.scalars( + select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id) + ).all(): + product.visible = visible + db.commit() - products = db.scalars( - select(Product) - .where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id) - .options(joinedload(Product.mix)) - .order_by(Product.client_name, Product.name, Product.id) - ).all() - return [_serialize_row(product) for product in products] + counts = _mix_product_counts(db, session.tenant_id or "") + total, visible_count = counts.get(mix_id, (0, 0)) + return _serialize_mix_row(mix, visible_count=visible_count, product_count=total) + + +@router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead) +def get_editor_mix_ingredients( + mix_id: int, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") + if mix is None: + raise HTTPException(status_code=404, detail="Mix not found") + return _serialize_mix_formula(mix) + + +@router.post("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead, status_code=201) +def add_editor_mix_ingredient( + mix_id: int, + payload: EditorMixIngredientCreate, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") + if mix is None: + raise HTTPException(status_code=404, detail="Mix not found") + if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None: + raise HTTPException(status_code=404, detail="Raw material not found") + + db.add( + MixIngredient( + tenant_id=session.tenant_id or "", + mix_id=mix_id, + raw_material_id=payload.raw_material_id, + quantity_kg=payload.quantity_kg, + notes=payload.notes, + ) + ) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=400, detail="Raw material is already on this mix") from exc + + mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") + return _serialize_mix_formula(mix) + + +@router.patch("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead) +def update_editor_mix_ingredient( + mix_id: int, + ingredient_id: int, + payload: EditorMixIngredientUpdate, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + ingredient = db.scalar( + select(MixIngredient) + .join(Mix) + .where( + MixIngredient.id == ingredient_id, + MixIngredient.mix_id == mix_id, + Mix.tenant_id == session.tenant_id, + ) + ) + if ingredient is None: + raise HTTPException(status_code=404, detail="Ingredient not found") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(ingredient, field, value) + db.commit() + + mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") + return _serialize_mix_formula(mix) + + +@router.delete("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead) +def delete_editor_mix_ingredient( + mix_id: int, + ingredient_id: int, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + ingredient = db.scalar( + select(MixIngredient) + .join(Mix) + .where( + MixIngredient.id == ingredient_id, + MixIngredient.mix_id == mix_id, + Mix.tenant_id == session.tenant_id, + ) + ) + if ingredient is None: + raise HTTPException(status_code=404, detail="Ingredient not found") + db.delete(ingredient) + db.commit() + + mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") + return _serialize_mix_formula(mix) @router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead) @@ -282,3 +478,116 @@ def delete_editor_product_ingredient( product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "") return _serialize_product_formula(product) + + +# --- Ingredients (raw materials) catalogue ----------------------------------- +# +# The mix editor consumes raw materials as the ingredients dropdown; this gives +# Lean admins a sibling editor to curate that catalogue — the ingredients that +# ultimately get used inside mixes. Same auth/tenant model as the mix editor. + + +def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict: + active_price = get_active_price(material) + cost_per_kg = ( + calculate_raw_material_cost(material, active_price).cost_per_kg if active_price is not None else None + ) + return { + "id": material.id, + "name": material.name, + "supplier": material.supplier, + "unit_of_measure": material.unit_of_measure, + "kg_per_unit": material.kg_per_unit, + "status": material.status, + "rounding_decimals": material.rounding_decimals, + "notes": material.notes, + "cost_per_kg": cost_per_kg, + "usage_count": usage_count, + "created_at": material.created_at, + } + + +def _ingredient_usage_counts(db: Session, tenant_id: str) -> dict[int, int]: + """How many product/mix formula rows reference each raw material.""" + rows = db.execute( + select(ProductIngredient.raw_material_id, func.count()) + .where(ProductIngredient.tenant_id == tenant_id) + .group_by(ProductIngredient.raw_material_id) + ).all() + return {raw_material_id: count for raw_material_id, count in rows} + + +@router.get("/ingredients", response_model=list[EditorIngredientRow]) +def list_editor_ingredients( + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + tenant_id = session.tenant_id or "" + materials = db.scalars( + select(RawMaterial) + .where(RawMaterial.tenant_id == tenant_id) + .options(selectinload(RawMaterial.price_versions)) + .order_by(RawMaterial.name) + ).all() + usage = _ingredient_usage_counts(db, tenant_id) + return [_serialize_ingredient(material, usage.get(material.id, 0)) for material in materials] + + +@router.post("/ingredients", response_model=EditorIngredientRow, status_code=201) +def create_editor_ingredient( + payload: EditorIngredientCreate, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + material = RawMaterial( + tenant_id=session.tenant_id or "", + name=payload.name.strip(), + supplier=(payload.supplier or "").strip() or None, + unit_of_measure=payload.unit_of_measure.strip(), + kg_per_unit=payload.kg_per_unit, + status=payload.status.strip() or "active", + rounding_decimals=payload.rounding_decimals, + notes=payload.notes, + ) + db.add(material) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc + db.refresh(material) + return _serialize_ingredient(material, 0) + + +@router.patch("/ingredients/{ingredient_id}", response_model=EditorIngredientRow) +def update_editor_ingredient( + ingredient_id: int, + payload: EditorIngredientUpdate, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + tenant_id = session.tenant_id or "" + material = db.scalar( + select(RawMaterial) + .where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id) + .options(selectinload(RawMaterial.price_versions)) + ) + if material is None: + raise HTTPException(status_code=404, detail="Ingredient not found") + updates = payload.model_dump(exclude_unset=True) + if "name" in updates and updates["name"] is not None: + updates["name"] = updates["name"].strip() + if "supplier" in updates: + updates["supplier"] = (updates["supplier"] or "").strip() or None + if "unit_of_measure" in updates and updates["unit_of_measure"] is not None: + updates["unit_of_measure"] = updates["unit_of_measure"].strip() + for field, value in updates.items(): + setattr(material, field, value) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc + db.refresh(material) + usage = _ingredient_usage_counts(db, tenant_id) + return _serialize_ingredient(material, usage.get(material.id, 0)) diff --git a/backend/app/api/ordering_admin.py b/backend/app/api/ordering_admin.py index 16c3124..dd04acc 100644 --- a/backend/app/api/ordering_admin.py +++ b/backend/app/api/ordering_admin.py @@ -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) diff --git a/backend/app/api/throughput.py b/backend/app/api/throughput.py index 38e5fd2..ae708c2 100644 --- a/backend/app/api/throughput.py +++ b/backend/app/api/throughput.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import date -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from sqlalchemy import select from sqlalchemy.orm import Session @@ -13,16 +13,21 @@ from app.schemas.throughput import ( ThroughputEntryCreate, ThroughputEntryRead, ThroughputEntryUpdate, + ThroughputImportResult, ThroughputProductCreate, ThroughputProductRead, ThroughputProductUpdate, ) from app.services.throughput_service import ( calculate_kg, + import_entries_from_file, normalise_staff_name, serialize_entry, ) +# Uploaded files larger than this are rejected before we read them into memory. +_MAX_IMPORT_BYTES = 10 * 1024 * 1024 # 10 MB + router = APIRouter(prefix="/api/throughput", tags=["operations-throughput"]) MODULE_KEY = "operations_throughput" @@ -184,6 +189,33 @@ def create_entry( return serialize_entry(entry) +@router.post("/import", response_model=ThroughputImportResult) +def import_entries( + file: UploadFile = File(...), + session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")), + db: Session = Depends(get_db), +): + content = file.file.read() + if not content: + raise HTTPException(status_code=400, detail="The uploaded file is empty.") + if len(content) > _MAX_IMPORT_BYTES: + raise HTTPException(status_code=413, detail="File is too large. Keep uploads under 10 MB.") + + try: + result = import_entries_from_file( + db, + filename=file.filename or "upload.csv", + content=content, + tenant_id=session.tenant_id, + created_by=session.email, + ) + except ValueError as exc: + db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return result + + @router.get("/entries/{entry_id}", response_model=ThroughputEntryRead) def get_entry( entry_id: int, @@ -233,7 +265,10 @@ def update_entry( @router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_entry( entry_id: int, - session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "manage")), + # Correcting a mistaken run is part of day-to-day operating, so deleting an + # entry sits at the same "edit" level as adding/editing one. (No throughput + # role is granted "manage", so requiring it here would 403 everyone.) + session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")), db: Session = Depends(get_db), ): entry = db.scalar( diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index 60a5ed5..0fc2fbd 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -129,6 +129,8 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = ( ("production_throughput_entries", "for_stock", "BOOLEAN NOT NULL DEFAULT FALSE"), ("production_throughput_entries", "job_number", "VARCHAR(64)"), ("production_throughput_entries", "stock_quantity", "FLOAT"), + ("raw_materials", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"), + ("mix_calculator_session_lines", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"), ) diff --git a/backend/app/models/mix_calculator.py b/backend/app/models/mix_calculator.py index 23a7b9a..f08c96e 100644 --- a/backend/app/models/mix_calculator.py +++ b/backend/app/models/mix_calculator.py @@ -52,6 +52,8 @@ class MixCalculatorSessionLine(Base): required_kg: Mapped[float] = mapped_column(Float) mix_percentage: Mapped[float] = mapped_column(Float) unit: Mapped[str] = mapped_column(String(64)) + # Snapshot of the ingredient's rounding setting at save time. + rounding_decimals: Mapped[int] = mapped_column(Integer, default=2) sort_order: Mapped[int] = mapped_column(Integer, default=0) session: Mapped[MixCalculatorSession] = relationship(back_populates="lines") diff --git a/backend/app/models/ordering.py b/backend/app/models/ordering.py index 60ecb7a..8f6f3b3 100644 --- a/backend/app/models/ordering.py +++ b/backend/app/models/ordering.py @@ -376,6 +376,34 @@ class NotificationSetting(Base): created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) +class XeroContactLink(Base): + """Persistent link between a customer (:class:`ClientAccount`) and a Xero + contact. + + Maintained from the Integrations console. Once a customer is linked, order + invoices reference the real Xero ``ContactID`` instead of falling back to the + client code — which is what lets Xero attach the invoice to the right + contact rather than creating a duplicate. One link per customer per tenant. + """ + + __tablename__ = "xero_contact_links" + __table_args__ = ( + UniqueConstraint("tenant_id", "client_account_id", name="uq_xero_contact_link_customer"), + ) + + 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) + xero_contact_id: Mapped[str] = mapped_column(String(128)) + xero_contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + xero_contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + # When the contact details were last reconciled with Xero (a future live + # sync can refresh name/email and stamp this). + last_synced_at: Mapped[datetime | None] = mapped_column(DateTime, 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) + + class XeroSyncLog(Base): __tablename__ = "xero_sync_log" diff --git a/backend/app/models/raw_material.py b/backend/app/models/raw_material.py index 1d0f0bc..48452e0 100644 --- a/backend/app/models/raw_material.py +++ b/backend/app/models/raw_material.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import date, datetime -from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text +from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.session import Base @@ -18,6 +18,9 @@ class RawMaterial(Base): unit_of_measure: Mapped[str] = mapped_column(String(64)) kg_per_unit: Mapped[float] = mapped_column(Float) status: Mapped[str] = mapped_column(String(32), default="active") + # Decimal places this ingredient's required-kg is rounded to in the mix + # calculator output. Set per-ingredient from the Ingredients Editor. + rounding_decimals: Mapped[int] = mapped_column(Integer, default=2) notes: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/backend/app/schemas/editor.py b/backend/app/schemas/editor.py index e855329..23f5ca5 100644 --- a/backend/app/schemas/editor.py +++ b/backend/app/schemas/editor.py @@ -1,3 +1,5 @@ +from datetime import datetime + from pydantic import BaseModel, ConfigDict, Field @@ -36,6 +38,52 @@ class EditorMixUpdate(BaseModel): client_name: str | None = Field(default=None, min_length=1, max_length=255) name: str | None = Field(default=None, min_length=1, max_length=255) notes: str | None = Field(default=None, max_length=2000) + # Toggling a mix's status fans out to the visibility of every product under it. + visible: bool | None = None + + +class EditorMixRow(BaseModel): + id: int + tenant_id: str + client_name: str + name: str + # A mix reads as "Active" when at least one of its products is visible. + visible: bool + product_count: int + visible_product_count: int + notes: str | None + + +class EditorMixIngredientRead(BaseModel): + id: int + raw_material_id: int + raw_material_name: str + quantity_kg: float + notes: str | None + + +class EditorMixFormulaRead(BaseModel): + id: int + tenant_id: str + client_name: str + name: str + ingredients: list[EditorMixIngredientRead] + total_kg: float + + +class EditorMixIngredientCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + raw_material_id: int + quantity_kg: float = Field(gt=0) + notes: str | None = Field(default=None, max_length=1000) + + +class EditorMixIngredientUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + quantity_kg: float | None = Field(default=None, gt=0) + notes: str | None = Field(default=None, max_length=1000) class EditorProductIngredientCreate(BaseModel): @@ -71,3 +119,46 @@ class EditorProductFormulaRead(BaseModel): mix_name: str ingredients: list[EditorProductIngredientRead] total_kg: float + + +# --- Ingredients (raw materials) catalogue ----------------------------------- + + +class EditorIngredientRow(BaseModel): + id: int + name: str + supplier: str | None + unit_of_measure: str + kg_per_unit: float + status: str + # Decimal places this ingredient is rounded to in the mix calculator output. + rounding_decimals: int + notes: str | None + cost_per_kg: float | None + # How many product/mix formulas currently reference this ingredient. + usage_count: int + created_at: datetime + + +class EditorIngredientCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + supplier: str | None = Field(default=None, max_length=255) + unit_of_measure: str = Field(min_length=1, max_length=64) + kg_per_unit: float = Field(gt=0) + status: str = Field(default="active", max_length=32) + rounding_decimals: int = Field(default=2, ge=0, le=6) + notes: str | None = Field(default=None, max_length=2000) + + +class EditorIngredientUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, max_length=255) + supplier: str | None = Field(default=None, max_length=255) + unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64) + kg_per_unit: float | None = Field(default=None, gt=0) + status: str | None = Field(default=None, max_length=32) + rounding_decimals: int | None = Field(default=None, ge=0, le=6) + notes: str | None = Field(default=None, max_length=2000) diff --git a/backend/app/schemas/mix_calculator.py b/backend/app/schemas/mix_calculator.py index 98886fd..b46ab17 100644 --- a/backend/app/schemas/mix_calculator.py +++ b/backend/app/schemas/mix_calculator.py @@ -26,6 +26,7 @@ class MixCalculatorSessionLineRead(BaseModel): required_kg: float mix_percentage: float unit: str + rounding_decimals: int = 2 sort_order: int diff --git a/backend/app/schemas/ordering.py b/backend/app/schemas/ordering.py index 4c74818..9840a51 100644 --- a/backend/app/schemas/ordering.py +++ b/backend/app/schemas/ordering.py @@ -238,6 +238,18 @@ class NotificationSettingsUpdate(BaseModel): from_email: str | None = None +# --- Admin: Xero integration ------------------------------------------------- + + +class XeroContactLinkUpsert(BaseModel): + """Link a customer to a Xero contact. ``xero_contact_id`` is the Xero + ``ContactID`` (or, in stub mode, the deterministic stub id).""" + + xero_contact_id: str = Field(min_length=1, max_length=128) + xero_contact_name: str | None = Field(default=None, max_length=255) + xero_contact_email: str | None = Field(default=None, max_length=255) + + # --- Admin: customers & users ------------------------------------------------ _CUSTOMER_STATUSES = {"active", "disabled"} diff --git a/backend/app/schemas/throughput.py b/backend/app/schemas/throughput.py index 94ede52..c1220be 100644 --- a/backend/app/schemas/throughput.py +++ b/backend/app/schemas/throughput.py @@ -117,6 +117,13 @@ class ThroughputEntryUpdate(BaseModel): notes: str | None = Field(default=None, max_length=2000) +class ThroughputImportResult(BaseModel): + entries_imported: int + entries_skipped: int + products_created: int + errors: list[str] = Field(default_factory=list) + + class ThroughputEntryRead(BaseModel): id: int tenant_id: str diff --git a/backend/app/services/mix_calculator_pdf.py b/backend/app/services/mix_calculator_pdf.py index 99df75a..f5cf911 100644 --- a/backend/app/services/mix_calculator_pdf.py +++ b/backend/app/services/mix_calculator_pdf.py @@ -319,7 +319,10 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210), ) pdf.setFont("Helvetica", table_font_size) - pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}kg") + # Each ingredient carries its own rounding (set in the Ingredients Editor) + # so the printed sheet matches the on-screen calculated output. + line_decimals = getattr(line, "rounding_decimals", 2) + pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg, line_decimals)}kg") strip_y = table_bottom - 6 if note_lines: diff --git a/backend/app/services/mix_calculator_service.py b/backend/app/services/mix_calculator_service.py index 84453e8..05c1ffa 100644 --- a/backend/app/services/mix_calculator_service.py +++ b/backend/app/services/mix_calculator_service.py @@ -43,6 +43,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]: "raw_material_name": ingredient.raw_material.name, "quantity_kg": ingredient.quantity_kg, "unit": ingredient.raw_material.unit_of_measure, + "rounding_decimals": ingredient.raw_material.rounding_decimals, "sort_order": ingredient.sort_order, } for ingredient in product.ingredients @@ -55,6 +56,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]: "raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}", "quantity_kg": ingredient.quantity_kg, "unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg", + "rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2, "sort_order": index, } for index, ingredient in enumerate(product.mix.ingredients, start=1) @@ -128,6 +130,7 @@ def calculate_mix_calculator_preview( "required_kg": required_kg, "mix_percentage": mix_percentage, "unit": ingredient["unit"], + "rounding_decimals": ingredient.get("rounding_decimals", 2), "sort_order": ingredient["sort_order"] or index, } ) @@ -260,6 +263,7 @@ def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_ "required_kg": round(line.required_kg, 4), "mix_percentage": round(line.mix_percentage, 4), "unit": line.unit, + "rounding_decimals": line.rounding_decimals, "sort_order": line.sort_order, } for line in session_record.lines @@ -331,6 +335,7 @@ def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, pay required_kg=line["required_kg"], mix_percentage=line["mix_percentage"], unit=line["unit"], + rounding_decimals=line.get("rounding_decimals", 2), sort_order=line["sort_order"], ) for line in preview["lines"] diff --git a/backend/app/services/throughput_service.py b/backend/app/services/throughput_service.py index 0b7f570..27c5455 100644 --- a/backend/app/services/throughput_service.py +++ b/backend/app/services/throughput_service.py @@ -1,5 +1,7 @@ from __future__ import annotations +import csv +import io import logging import os from datetime import date, datetime @@ -369,3 +371,310 @@ def resolve_workbook_path() -> Path | None: if candidate.exists(): return candidate return None + + +# ── Ad-hoc CSV / spreadsheet upload import ────────────────────────────────── +# Lets an operator upload their own CSV or .xlsx of packing runs (from Settings +# → Import) and have every row saved as a throughput entry. Unlike the bundled +# workbook seed above, this is column-header driven so the file can be a simple +# hand-built sheet rather than the exact "Operations Throughput.xlsx" layout. + +# Maps the column headers we accept (normalised: lower-cased, spaces/dashes → +# single spaces) onto the canonical field used internally. Several aliases per +# field so a human-built sheet "just works". +_HEADER_ALIASES: dict[str, str] = { + "date": "date", + "production date": "date", + "production_date": "date", + "product": "product", + "product name": "product", + "product_name": "product", + "product name snapshot": "product", + "name": "product", + "item id": "item_id", + "item_id": "item_id", + "itemid": "item_id", + "sku": "item_id", + "quantity": "quantity", + "qty": "quantity", + "packed": "quantity", + "quantity packed": "quantity", + "amount": "quantity", + "quantity type": "quantity_type", + "type": "quantity_type", + "unit": "quantity_type", + "packed as": "quantity_type", + "bag size": "bag_size", + "bag_size": "bag_size", + "kg per bag": "bag_size", + "kg/bag": "bag_size", + "bagsize": "bag_size", + "staff": "staff_name", + "staff name": "staff_name", + "packed by": "staff_name", + "operator": "staff_name", + "for order": "for_order", + "order": "for_order", + "for stock": "for_stock", + "stock": "for_stock", + "job number": "job_number", + "job": "job_number", + "job no": "job_number", + "order number": "job_number", + "stock quantity": "stock_quantity", + "stock qty": "stock_quantity", + "sample box no": "sample_box_no", + "sample box": "sample_box_no", + "scales checked": "scales_checked", + "scales": "scales_checked", + "label correct": "label_correct", + "label": "label_correct", + "bag sealed": "bag_sealed", + "sealed": "bag_sealed", + "pallet good condition": "pallet_good_condition", + "pallet": "pallet_good_condition", + "notes": "notes", + "note": "notes", + "comment": "notes", + "comments": "notes", +} + +# How many row-level errors we collect before truncating, to keep the response +# (and the toast) sane on a badly-formed file. +_MAX_REPORTED_ERRORS = 50 + + +def _normalise_header(raw: object) -> str | None: + if raw is None: + return None + key = " ".join(str(raw).strip().lower().replace("-", " ").replace("_", " ").split()) + if not key: + return None + if key in _HEADER_ALIASES: + return _HEADER_ALIASES[key] + # Test weights: "test weight 1".."test weight 5" (and "tw1" style). + for n in range(1, 6): + if key in {f"test weight {n}", f"tw{n}", f"test {n}"}: + return f"test_weight_{n}" + return None + + +def _coerce_quantity_type(value: object) -> str | None: + if value is None: + return None + text = str(value).strip().lower() + if not text: + return None + if text in {"bag", "bags", "b"}: + return "bags" + if text in {"kg", "kgs", "kilogram", "kilograms", "bulka", "bulk"}: + return "kg" + return None + + +def _read_tabular_file(filename: str, content: bytes) -> tuple[list[str | None], list[tuple]]: + """Return (headers, data_rows). Detects CSV vs .xlsx by extension/content.""" + lowered = (filename or "").lower() + is_excel = lowered.endswith((".xlsx", ".xlsm", ".xls")) + + if is_excel: + workbook = load_workbook(io.BytesIO(content), data_only=True, read_only=True) + ws = workbook.active + rows = [tuple(r) for r in ws.iter_rows(values_only=True)] + workbook.close() + else: + text = None + for encoding in ("utf-8-sig", "utf-8", "latin-1"): + try: + text = content.decode(encoding) + break + except UnicodeDecodeError: + continue + if text is None: + raise ValueError("Could not decode the file as text. Save it as UTF-8 CSV or .xlsx.") + # Sniff the delimiter (comma/semicolon/tab) but fall back to comma. + sample = text[:4096] + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except csv.Error: + dialect = csv.excel + rows = [tuple(r) for r in csv.reader(io.StringIO(text), dialect)] + + # Find the first row that has at least one recognised header; treat it as + # the header row and everything after as data. + for index, row in enumerate(rows): + if any(_normalise_header(cell) is not None for cell in row): + return list(row), rows[index + 1 :] + + return [], [] + + +def import_entries_from_file( + db: Session, + *, + filename: str, + content: bytes, + tenant_id: str, + created_by: str | None, +) -> dict: + """Parse an uploaded CSV/spreadsheet and persist each row as a throughput + entry. Products are matched by item_id then name, and auto-created when not + found so every entry stays linked. Returns a summary with row-level errors. + """ + headers, data_rows = _read_tabular_file(filename, content) + if not headers: + raise ValueError( + "No recognised columns found. The file needs a header row with at " + "least Date, Product and Quantity columns." + ) + + # Map canonical field name → column index. First occurrence wins. + field_index: dict[str, int] = {} + for col, raw in enumerate(headers): + field = _normalise_header(raw) + if field and field not in field_index: + field_index[field] = col + + for required in ("date", "product", "quantity"): + if required not in field_index: + raise ValueError( + f"Missing required '{required}' column. Required columns are " + "Date, Product and Quantity." + ) + + def cell(row: tuple, field: str) -> object: + idx = field_index.get(field) + if idx is None or idx >= len(row): + return None + return row[idx] + + # Index existing products for matching (by item_id and by lower-cased name). + by_item: dict[str, ThroughputProduct] = {} + by_name: dict[str, ThroughputProduct] = {} + for product in db.scalars( + select(ThroughputProduct).where(ThroughputProduct.tenant_id == tenant_id) + ).all(): + if product.item_id: + by_item[str(product.item_id)] = product + by_name[product.name.lower()] = product + + imported = 0 + skipped = 0 + products_created = 0 + errors: list[str] = [] + + def note_error(message: str) -> None: + if len(errors) < _MAX_REPORTED_ERRORS: + errors.append(message) + + for offset, row in enumerate(data_rows): + # Sheet/file row number for human-friendly error messages (header = 1). + line_no = offset + 2 + if not row or all(value is None or str(value).strip() == "" for value in row): + continue + + production_date = _coerce_date(cell(row, "date")) + product_name = _coerce_text(cell(row, "product")) + quantity = _coerce_float(cell(row, "quantity")) + + if production_date is None: + skipped += 1 + note_error(f"Row {line_no}: missing or invalid date.") + continue + if not product_name: + skipped += 1 + note_error(f"Row {line_no}: missing product name.") + continue + if quantity is None or quantity < 0: + skipped += 1 + note_error(f"Row {line_no}: missing or invalid quantity.") + continue + + bag_size = _coerce_float(cell(row, "bag_size")) + + quantity_type = _coerce_quantity_type(cell(row, "quantity_type")) + if quantity_type is None: + # Infer: bulka-style rows have a blank or very large bag size. + if bag_size is None or bag_size >= _BULKA_BAG_SIZE_THRESHOLD or "bulka" in product_name.lower(): + quantity_type = "kg" + else: + quantity_type = "bags" + + if quantity_type == "bags" and (bag_size is None or bag_size <= 0): + skipped += 1 + note_error(f"Row {line_no}: bag size is required when packed as bags.") + continue + + item_id_raw = cell(row, "item_id") + item_id = None + if item_id_raw is not None: + if isinstance(item_id_raw, float) and item_id_raw.is_integer(): + item_id = str(int(item_id_raw)) + else: + item_id = _coerce_text(item_id_raw) + + product = (by_item.get(item_id) if item_id else None) or by_name.get(product_name.lower()) + if product is None: + product = ThroughputProduct( + tenant_id=tenant_id, + item_id=item_id, + name=product_name, + default_bag_size=bag_size, + is_bulka_default=_infer_bulka_default(product_name, bag_size), + active=True, + notes="Auto-created during throughput import", + ) + db.add(product) + db.flush() + products_created += 1 + if item_id: + by_item[item_id] = product + by_name[product_name.lower()] = product + + for_order = _coerce_bool(cell(row, "for_order")) if field_index.get("for_order") is not None else False + for_stock = _coerce_bool(cell(row, "for_stock")) if field_index.get("for_stock") is not None else False + stock_quantity = _coerce_float(cell(row, "stock_quantity")) if for_stock else None + + calculated = calculate_kg(quantity, quantity_type, bag_size) + entry = ProductionThroughput( + tenant_id=tenant_id, + production_date=production_date, + product_id=product.id, + product_name_snapshot=product_name, + bag_size=bag_size, + scales_checked=_coerce_bool(cell(row, "scales_checked")), + label_correct=_coerce_bool(cell(row, "label_correct")), + bag_sealed=_coerce_bool(cell(row, "bag_sealed")), + pallet_good_condition=_coerce_bool(cell(row, "pallet_good_condition")), + for_order=for_order, + for_stock=for_stock, + job_number=_coerce_text(cell(row, "job_number")) if for_order else None, + stock_quantity=stock_quantity, + sample_box_no=_coerce_text(cell(row, "sample_box_no")), + test_weight_1=_coerce_float(cell(row, "test_weight_1")), + test_weight_2=_coerce_float(cell(row, "test_weight_2")), + test_weight_3=_coerce_float(cell(row, "test_weight_3")), + test_weight_4=_coerce_float(cell(row, "test_weight_4")), + test_weight_5=_coerce_float(cell(row, "test_weight_5")), + quantity=quantity, + quantity_type=quantity_type, + calculated_kg=calculated, + staff_name=normalise_staff_name(cell(row, "staff_name")), + notes=_coerce_text(cell(row, "notes")), + created_by=created_by or "csv-import", + ) + db.add(entry) + imported += 1 + + if imported == 0 and products_created == 0: + # Nothing landed — don't leave a half-open transaction. + db.rollback() + else: + db.commit() + + return { + "entries_imported": imported, + "entries_skipped": skipped, + "products_created": products_created, + "errors": errors, + } diff --git a/backend/app/services/xero_service.py b/backend/app/services/xero_service.py index 3e3aa66..a4aed9c 100644 --- a/backend/app/services/xero_service.py +++ b/backend/app/services/xero_service.py @@ -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: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index af1d27a..a0f23a7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "data-entry-app-backend" -version = "0.1.14" +version = "0.1.19" description = "Costing platform MVP backend" requires-python = ">=3.11" dependencies = [ "fastapi>=0.115,<1.0", + "python-multipart>=0.0.9,<1.0", "openpyxl>=3.1,<4.0", "rich>=13.9,<15.0", "uvicorn[standard]>=0.30,<1.0", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 86a70de..45c7a3e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.12", + "version": "0.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.12", + "version": "0.1.18", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index 20be484..f6e9421 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.14", + "version": "0.1.19", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/actions/tooltip.ts b/frontend/src/lib/actions/tooltip.ts new file mode 100644 index 0000000..7ae224a --- /dev/null +++ b/frontend/src/lib/actions/tooltip.ts @@ -0,0 +1,100 @@ +/** + * Modern hover/focus tooltip action. + * + * Renders a styled bubble appended to with fixed positioning, so it is + * never clipped by an overflow:hidden ancestor (e.g. the topbar). Themes via the + * shared design tokens — see `.app-tooltip` in styles/theme.css. + * + * Usage: + * + */ +type Placement = 'top' | 'bottom'; + +type TooltipOptions = string | { label: string; placement?: Placement; delay?: number }; + +const GAP = 8; +const DEFAULT_DELAY = 300; + +function normalize(options: TooltipOptions): { label: string; placement: Placement; delay: number } { + if (typeof options === 'string') { + return { label: options, placement: 'bottom', delay: DEFAULT_DELAY }; + } + return { + label: options.label, + placement: options.placement ?? 'bottom', + delay: options.delay ?? DEFAULT_DELAY + }; +} + +export function tooltip(node: HTMLElement, options: TooltipOptions) { + let current = normalize(options); + let bubble: HTMLDivElement | null = null; + let showTimer: ReturnType | null = null; + + function position() { + if (!bubble) return; + const anchor = node.getBoundingClientRect(); + const tip = bubble.getBoundingClientRect(); + + let left = anchor.left + anchor.width / 2 - tip.width / 2; + left = Math.max(GAP, Math.min(left, window.innerWidth - tip.width - GAP)); + + const top = + current.placement === 'top' + ? anchor.top - tip.height - GAP + : anchor.bottom + GAP; + + bubble.style.left = `${Math.round(left)}px`; + bubble.style.top = `${Math.round(top)}px`; + } + + function show() { + if (bubble || !current.label) return; + bubble = document.createElement('div'); + bubble.className = 'app-tooltip'; + bubble.setAttribute('role', 'tooltip'); + bubble.dataset.placement = current.placement; + bubble.textContent = current.label; + document.body.appendChild(bubble); + position(); + // Trigger the fade/translate transition on the next frame. + requestAnimationFrame(() => bubble?.classList.add('is-visible')); + } + + function scheduleShow() { + clearTimeout(showTimer ?? undefined); + showTimer = setTimeout(show, current.delay); + } + + function hide() { + clearTimeout(showTimer ?? undefined); + showTimer = null; + bubble?.remove(); + bubble = null; + } + + node.addEventListener('mouseenter', scheduleShow); + node.addEventListener('mouseleave', hide); + node.addEventListener('focus', show); + node.addEventListener('blur', hide); + node.addEventListener('click', hide); + + return { + update(next: TooltipOptions) { + current = normalize(next); + if (bubble) { + bubble.textContent = current.label; + bubble.dataset.placement = current.placement; + position(); + } + }, + destroy() { + hide(); + node.removeEventListener('mouseenter', scheduleShow); + node.removeEventListener('mouseleave', hide); + node.removeEventListener('focus', show); + node.removeEventListener('blur', hide); + node.removeEventListener('click', hide); + } + }; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c6d3bff..d02694c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -9,6 +9,11 @@ import type { ClientUserUpdateInput, LoginResponse, EditorMixUpdateInput, + EditorMixRow, + EditorMixFormula, + EditorIngredientRow, + EditorIngredientCreateInput, + EditorIngredientUpdateInput, EditorProductFormula, EditorProductRow, EditorProductUpdateInput, @@ -38,10 +43,14 @@ import type { OrderingCustomerUser, OrderingNotificationSettings, XeroStatus, + XeroContactList, + XeroContactLinkRow, Scenario, ThroughputEntry, ThroughputEntryCreateInput, + ThroughputEntryUpdateInput, ThroughputEntryListParams, + ThroughputImportResult, ThroughputProduct, ThroughputProductCreateInput, ThroughputProductUpdateInput @@ -250,6 +259,45 @@ async function request( } } +// Multipart upload. Unlike `request`, we must NOT set Content-Type ourselves — +// the browser sets `multipart/form-data` with the correct boundary when given a +// FormData body. Mirrors `request`'s auth/cache/error handling otherwise. +async function uploadFile( + path: string, + formData: FormData, + auth: AuthMode = 'none', + fetcher: ApiFetch = fetch +): Promise { + try { + const response = await fetcher(resolveRequestUrl(path, fetcher), { + method: 'POST', + body: formData, + credentials: 'include' + }); + + if (!response.ok) { + let message = 'Request failed'; + try { + const body = (await response.json()) as { detail?: string }; + message = body.detail ?? message; + } catch { + message = response.statusText || message; + } + throw new Error(message); + } + + if (browser) { + clearApiCache(); + } + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; + } catch (error) { + throw normalizeRequestError(error); + } +} + async function requestBlob( path: string, options: RequestInit = {}, @@ -330,11 +378,36 @@ export const api = { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), + editorMixes: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => { + const search = new URLSearchParams(); + if (params?.q) search.set('q', params.q); + if (params?.client_name) search.set('client_name', params.client_name); + if (params?.limit) search.set('limit', String(params.limit)); + const qs = search.toString(); + const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes'; + return cachedFetchJson(path, 'client', fetcher); + }, updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) => - request(`/api/editor/mixes/${mixId}`, { + request(`/api/editor/mixes/${mixId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), + editorMixFormula: (mixId: number) => + request(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'), + addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) => + request(`/api/editor/mixes/${mixId}/ingredients`, { + method: 'POST', + body: JSON.stringify(payload) + }, 'client'), + updateEditorMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) => + request(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), + deleteEditorMixIngredient: (mixId: number, ingredientId: number) => + request(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, { + method: 'DELETE' + }, 'client'), editorProductFormula: (productId: number) => request(`/api/editor/products/${productId}/ingredients`, {}, 'client'), addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) => @@ -351,6 +424,18 @@ export const api = { request(`/api/editor/products/${productId}/ingredients/${ingredientId}`, { method: 'DELETE' }, 'client'), + editorIngredients: (fetcher?: ApiFetch) => + cachedFetchJson('/api/editor/ingredients', 'client', fetcher), + createEditorIngredient: (payload: EditorIngredientCreateInput) => + request('/api/editor/ingredients', { + method: 'POST', + body: JSON.stringify(payload) + }, 'client'), + updateEditorIngredient: (ingredientId: number, payload: EditorIngredientUpdateInput) => + request(`/api/editor/ingredients/${ingredientId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), productCosts: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/product-costs', 'client', fetcher), productCostingItems: (fetcher?: ApiFetch) => @@ -391,6 +476,18 @@ export const api = { method: 'POST', body: JSON.stringify(payload) }, 'client'), + updateThroughputEntry: (entryId: number, payload: ThroughputEntryUpdateInput) => + request(`/api/throughput/entries/${entryId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), + deleteThroughputEntry: (entryId: number) => + request(`/api/throughput/entries/${entryId}`, { method: 'DELETE' }, 'client'), + importThroughputEntries: (file: File) => { + const formData = new FormData(); + formData.append('file', file); + return uploadFile('/api/throughput/import', formData, 'client'); + }, createThroughputProduct: (payload: ThroughputProductCreateInput) => request('/api/throughput/products', { method: 'POST', @@ -579,6 +676,16 @@ export const api = { cachedFetchJson('/api/ordering-admin/notification-settings', 'client', fetcher), updateNotificationSettings: (payload: Partial) => request('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), - xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson('/api/ordering-admin/xero/status', 'client', fetcher) + xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson('/api/ordering-admin/xero/status', 'client', fetcher), + xeroContacts: (fetcher?: ApiFetch) => + cachedFetchJson('/api/ordering-admin/xero/contacts', 'client', fetcher), + xeroContactLinks: (fetcher?: ApiFetch) => + cachedFetchJson('/api/ordering-admin/xero/contact-links', 'client', fetcher), + linkCustomerToXero: ( + customerId: number, + payload: { xero_contact_id: string; xero_contact_name?: string | null; xero_contact_email?: string | null } + ) => request(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'), + unlinkCustomerFromXero: (customerId: number) => + request(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'DELETE' }, 'client') } }; diff --git a/frontend/src/lib/changelog.ts b/frontend/src/lib/changelog.ts index 4e3258f..15eba86 100644 --- a/frontend/src/lib/changelog.ts +++ b/frontend/src/lib/changelog.ts @@ -8,7 +8,7 @@ import packageInfo from '../../package.json'; */ export type ChangelogEntry = { version: string; - /** ISO date (YYYY-MM-DD) the version shipped. */ + /** ISO date (YYYY-MM-DD) the version shipped. */a date: string; highlights: string[]; }; @@ -17,14 +17,28 @@ export type ChangelogEntry = { export const APP_VERSION: string = packageInfo.version; export const changelog: ChangelogEntry[] = [ + { + version: '0.1.18', + date: '2026-06-12', + highlights: [ + 'App - Improvements', + 'App - Bug fixes' + ] + }, + { + version: '0.1.17', + date: '2026-06-12', + highlights: [ + 'App - Improvements', + 'App - Mix Calculator bug fixes' + ] + }, { 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.' + 'Web App: Improved mix calculator', + 'Web App: Improved design' ] }, { diff --git a/frontend/src/lib/components/AuthGate.svelte b/frontend/src/lib/components/AuthGate.svelte index 0cbad90..aa976b9 100644 --- a/frontend/src/lib/components/AuthGate.svelte +++ b/frontend/src/lib/components/AuthGate.svelte @@ -9,43 +9,146 @@ {#if blocked} -
-

{label}

-

{title}

-

{detail}

-
+
+
+ +

{label}

+

{title}

+

{detail}

+
+
{:else} {@render children()} {/if} diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index 788d94f..87a1de3 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -15,7 +15,6 @@ import { canCreateMixSession as sessionCanCreateMixSession, canCreateMixWorksheet as sessionCanCreateMixWorksheet, - canOpenClientAccess as sessionCanOpenClientAccess, canOpenDashboard as sessionCanOpenDashboard, canOpenEditor as sessionCanOpenEditor, canOpenMixCalculator as sessionCanOpenMixCalculator, @@ -32,21 +31,24 @@ isWorkspaceRouteAllowed } from '$lib/workspace-access'; import { - accessControlItem, baseSearchItems, buildClientNavEntries, clientBreadcrumbs, dashboardItem, editorItem, + ingredientsEditorItem, footerLinks, matchesRoute, mixCalculatorItem, orderingItem, + orderingManageChildren, + orderingManageGroup, pageTitle, productCostingItem, reportingItem, throughputItem, type FooterLink, + type NavEntry, type SearchItem, type NavItem, workingDocumentItems @@ -89,7 +91,6 @@ const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession)); const canOpenEditor = $derived(sessionCanOpenEditor($clientSession)); const canOpenSettings = $derived(sessionCanOpenSettings($clientSession)); - const canOpenClientAccess = $derived(sessionCanOpenClientAccess($clientSession)); const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession)); const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession)); const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname)); @@ -116,15 +117,19 @@ // (/ordering/manage), customers get the catalogue (/ordering). const canManageOrdering = $derived(sessionCanManageOrdering($clientSession)); const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession)); - const visibleOrderingItem = $derived( + // Internal staff get the collapsible "Order Management" family (queue + + // products/customers/pricing/settings/integrations); customers get the single + // catalogue link. Either way it becomes one NavEntry the rail can render. + const visibleOrderingEntry = $derived( canManageOrdering - ? { ...orderingItem, href: '/ordering/manage', label: 'Order Management', shortLabel: 'OM' } + ? { kind: 'group', group: orderingManageGroup } : canOpenCustomerOrdering - ? orderingItem + ? { kind: 'item', item: orderingItem } : null ); const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null); const visibleEditorItem = $derived(canOpenEditor ? editorItem : null); + const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null); // Grouped desktop rail: Dashboard, a collapsible "Costing" family, then the // standalone operations/insights modules. Built from the same access-filtered // items, so a role only ever sees the families it may open. @@ -135,20 +140,18 @@ ...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []), ...(visibleProductCostingItem ? [visibleProductCostingItem] : []), ...(visibleEditorItem ? [visibleEditorItem] : []), + ...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []), ...visibleWorkingDocumentItems ], throughput: visibleThroughputItem, - ordering: visibleOrderingItem, + ordering: visibleOrderingEntry, reporting: visibleReportingItem }) ); const isOperationsUser = $derived($clientSession?.role_name === 'Operations'); const workspaceRole = $derived(getWorkspaceRole($clientSession)); const visibleFooterLinks = $derived([ - ...(!isOperationsUser ? footerLinks : []), - ...(!canOpenClientAccess - ? [] - : [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel, icon: accessControlItem.icon }]) + ...(!isOperationsUser ? footerLinks : []) ] as FooterLink[]); const primaryBottomNavigation = $derived( [ @@ -169,6 +172,7 @@ if (item.href === '/mix-calculator') return canOpenMixCalculator; if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession); if (item.href === '/editor') return canOpenEditor; + if (item.href === '/ingredients') return canOpenEditor; if (item.href === '/reporting') return sessionCanOpenReporting($clientSession); if (item.href === '/settings') return canOpenSettings; return true; @@ -219,12 +223,18 @@ } } - const filteredSearchItems = $derived( + // The palette previews at most this many rows. An admin can see every mix and + // session, so an unfiltered list would flood the dropdown; capping it keeps + // the preview short and pushes the user to type to narrow the match set. + const PALETTE_RESULT_LIMIT = 10; + const matchingSearchItems = $derived( searchItems.filter((item) => { const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase(); return haystack.includes(paletteQuery.trim().toLowerCase()); }) ); + const filteredSearchItems = $derived(matchingSearchItems.slice(0, PALETTE_RESULT_LIMIT)); + const hiddenResultCount = $derived(matchingSearchItems.length - filteredSearchItems.length); $effect(() => { page.url.pathname; @@ -476,6 +486,7 @@ }} onOpenSettings={openSettings} onSignOut={signOut} + onShowWhatsNew={() => (whatsNewOpen = true)} />
@@ -627,6 +638,29 @@ {/if} + {#if canManageOrdering} + {@const GroupIcon = orderingManageGroup.icon} + (navOpen = false)}> + + {orderingManageGroup.label} + +
+ {#each orderingManageChildren as child} + {@const ChildIcon = child.icon} + (navOpen = false)}> + + {child.label} + + {/each} +
+ {:else if canOpenCustomerOrdering} + {@const Icon = orderingItem.icon} + (navOpen = false)}> + + {orderingItem.label} + + {/if} + {#if visibleWorkingDocumentItems.length}
{#each visibleWorkingDocumentItems as item} @@ -724,6 +758,9 @@ {item.href} {/each} + {#if hiddenResultCount > 0} +

{hiddenResultCount} more {hiddenResultCount === 1 ? 'match' : 'matches'} — keep typing to narrow.

+ {/if} {:else}
No results @@ -1203,6 +1240,14 @@ justify-content: flex-start; } + .palette-more { + margin: 0.25rem 0.4rem 0.15rem; + padding: 0.5rem 0.52rem 0.2rem; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 0.78rem; + } + .bottom-nav, .bottom-drawer { display: none; diff --git a/frontend/src/lib/components/MixCalculatorPrintDocument.svelte b/frontend/src/lib/components/MixCalculatorPrintDocument.svelte index 1b0d89d..c7b22af 100644 --- a/frontend/src/lib/components/MixCalculatorPrintDocument.svelte +++ b/frontend/src/lib/components/MixCalculatorPrintDocument.svelte @@ -103,7 +103,7 @@ {line.raw_material_name} - {formatNumber(line.required_kg, 2)}kg + {formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg {/each} diff --git a/frontend/src/lib/components/ThemeToggle.svelte b/frontend/src/lib/components/ThemeToggle.svelte index acdd203..30d5b24 100644 --- a/frontend/src/lib/components/ThemeToggle.svelte +++ b/frontend/src/lib/components/ThemeToggle.svelte @@ -1,16 +1,20 @@ - - - - +{#if selectedOrder} + +{/if} diff --git a/frontend/src/routes/ordering/manage/+page.ts b/frontend/src/routes/ordering/manage/+page.ts index e93baa9..4cb21ec 100644 --- a/frontend/src/routes/ordering/manage/+page.ts +++ b/frontend/src/routes/ordering/manage/+page.ts @@ -1,30 +1,17 @@ -import { redirect } from '@sveltejs/kit'; -import { getStoredClientSession, hasStoredClientSession } from '$lib/session'; +import { 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; +import type { Order } from '$lib/types'; +// Orders queue. Access is already enforced by the family +layout.ts guard. 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)); + return { orders: [] as Order[] }; } 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 }; + const orders = await api.orderingAdmin.orders(undefined, fetch); + return { orders }; } catch { - return { ...EMPTY }; + return { orders: [] as Order[] }; } } diff --git a/frontend/src/routes/ordering/manage/customers/+page.svelte b/frontend/src/routes/ordering/manage/customers/+page.svelte new file mode 100644 index 0000000..1c8fcf9 --- /dev/null +++ b/frontend/src/routes/ordering/manage/customers/+page.svelte @@ -0,0 +1,184 @@ + + +
+
+

Customers ({customers.length})

+ +
+ + + + {#each customers as c (c.id)} + + + + + + + + {/each} + +
NameCodeUsersStatus
{c.client_code}{c.user_count}{c.status}
+
+ +{#if selectedCustomer} +
+

{selectedCustomer.name}

+ +

Users

+
    + {#each custUsers as u (u.id)} +
  • {u.full_name} · {u.email} · {u.role} · {u.status} + +
  • + {/each} +
+
+ + + + +
+ +

Product visibility

+
    + {#each custVisibility as row (row.product_id)} +
  • + +
  • + {/each} +
+ +

Manage discounts and per-product pricing for this customer on the Pricing page.

+
+{/if} + +{#if showNewCustomer} + +{/if} diff --git a/frontend/src/routes/ordering/manage/customers/+page.ts b/frontend/src/routes/ordering/manage/customers/+page.ts new file mode 100644 index 0000000..86fb299 --- /dev/null +++ b/frontend/src/routes/ordering/manage/customers/+page.ts @@ -0,0 +1,17 @@ +import { hasStoredClientSession } from '$lib/session'; +import { api } from '$lib/api'; +import type { OrderingCustomer } from '$lib/types'; + +// Customer accounts. Access enforced by the family +layout.ts guard. +export async function load({ fetch }) { + if (!hasStoredClientSession()) { + return { customers: [] as OrderingCustomer[] }; + } + + try { + const customers = await api.orderingAdmin.customers(fetch); + return { customers }; + } catch { + return { customers: [] as OrderingCustomer[] }; + } +} diff --git a/frontend/src/routes/ordering/manage/integrations/+page.svelte b/frontend/src/routes/ordering/manage/integrations/+page.svelte new file mode 100644 index 0000000..2b2af1b --- /dev/null +++ b/frontend/src/routes/ordering/manage/integrations/+page.svelte @@ -0,0 +1,60 @@ + + +
+

Integrations

+

Connect the ordering portal to the systems you already use.

+ +
+ + diff --git a/frontend/src/routes/ordering/manage/integrations/+page.ts b/frontend/src/routes/ordering/manage/integrations/+page.ts new file mode 100644 index 0000000..5785bc6 --- /dev/null +++ b/frontend/src/routes/ordering/manage/integrations/+page.ts @@ -0,0 +1,5 @@ +// Integrations landing. Individual integrations (Xero) load their own data on +// their nested routes. Access enforced by the family +layout.ts guard. +export function load() { + return {}; +} diff --git a/frontend/src/routes/ordering/manage/integrations/xero/+page.svelte b/frontend/src/routes/ordering/manage/integrations/xero/+page.svelte new file mode 100644 index 0000000..c3196a7 --- /dev/null +++ b/frontend/src/routes/ordering/manage/integrations/xero/+page.svelte @@ -0,0 +1,180 @@ + + +
+

Xero connection

+ {#if xero} +

Mode: {xero.connection.mode} · {xero.connection.configured ? 'Configured' : 'Not configured (stub mode)'}

+ {#if xero.connection.missing_env.length} +

Missing env: {xero.connection.missing_env.join(', ')}

+ {/if} +

+ Customers linked to a Xero contact: + {xero.contact_links.linked} of {xero.contact_links.total} + {#if xero.contact_links.unlinked}· {xero.contact_links.unlinked} unlinked{/if} +

+ {:else} +

Could not load integration status.

+ {/if} +
+ +
+
+

Customer ↔ Xero contact mapping

+ {linkedCount}/{links.length} linked +
+

+ Link each customer in our database to its contact in Xero. Once linked, that + customer's order invoices are raised against the matched Xero contact instead + of being matched by code. + {#if contactsStubbed}
Showing sample Xero contacts — live contacts appear once Xero credentials are configured.{/if} +

+ + {#if !links.length} +

No customers yet.

+ {:else} + + + + + + {#each links as row (row.customer_id)} + + + + + + + + {/each} + +
CustomerCodeXero contactStatus
{row.customer_name}{row.client_code} + + {#if !row.linked && row.suggested_contact_id} + suggested match + {/if} + + {#if row.linked} + Linked + {:else} + Unlinked + {/if} + + + {#if row.linked} + + {/if} +
+ {/if} +
+ +
+

Recent syncs

+ {#if !xero || !xero.recent_syncs.length} +

No Xero submissions yet.

+ {:else} +
    + {#each xero.recent_syncs as s (s.id)} +
  • Order {s.order_id} · {s.status} · {s.xero_invoice_id ?? '—'} · {new Date(s.created_at).toLocaleString('en-AU')}
  • + {/each} +
+ {/if} +
+ + diff --git a/frontend/src/routes/ordering/manage/integrations/xero/+page.ts b/frontend/src/routes/ordering/manage/integrations/xero/+page.ts new file mode 100644 index 0000000..ed1ad5e --- /dev/null +++ b/frontend/src/routes/ordering/manage/integrations/xero/+page.ts @@ -0,0 +1,37 @@ +import { hasStoredClientSession } from '$lib/session'; +import { api } from '$lib/api'; +import type { XeroContact, XeroContactLinkRow, XeroStatus } from '$lib/types'; + +// Xero integration status + customer→contact mapping. Access enforced by the +// family +layout.ts guard. +export async function load({ fetch }) { + if (!hasStoredClientSession()) { + return { + xero: null as XeroStatus | null, + contacts: [] as XeroContact[], + contactsStubbed: false, + links: [] as XeroContactLinkRow[] + }; + } + + try { + const [xero, contactList, links] = await Promise.all([ + api.orderingAdmin.xeroStatus(fetch), + api.orderingAdmin.xeroContacts(fetch), + api.orderingAdmin.xeroContactLinks(fetch) + ]); + return { + xero, + contacts: contactList.contacts, + contactsStubbed: contactList.stubbed, + links + }; + } catch { + return { + xero: null as XeroStatus | null, + contacts: [] as XeroContact[], + contactsStubbed: false, + links: [] as XeroContactLinkRow[] + }; + } +} diff --git a/frontend/src/routes/ordering/manage/pricing/+page.svelte b/frontend/src/routes/ordering/manage/pricing/+page.svelte new file mode 100644 index 0000000..48511f8 --- /dev/null +++ b/frontend/src/routes/ordering/manage/pricing/+page.svelte @@ -0,0 +1,116 @@ + + +
+

Customer pricing

+
+ +
+ + {#if !selectedCustomer} +

Choose a customer to view and edit their discount and per-product prices.

+ {:else} +

Default discount

+
+ + +
+ +

Per-product prices

+ {#if custPricing?.product_prices.length} +
    + {#each custPricing.product_prices as pp (pp.id)} +
  • {productName(pp.product_id)} · {pp.rule_type} · {pp.unit_price != null ? money(pp.unit_price) : 'quote'} + +
  • + {/each} +
+ {:else} +

No product-specific prices. The default discount applies to base prices.

+ {/if} +
+ + + + +
+ {/if} +
diff --git a/frontend/src/routes/ordering/manage/pricing/+page.ts b/frontend/src/routes/ordering/manage/pricing/+page.ts new file mode 100644 index 0000000..71c9ce9 --- /dev/null +++ b/frontend/src/routes/ordering/manage/pricing/+page.ts @@ -0,0 +1,22 @@ +import { hasStoredClientSession } from '$lib/session'; +import { api } from '$lib/api'; +import type { CatalogueProduct, OrderingCustomer } from '$lib/types'; + +// Pricing needs the customer list (to choose whose pricing to edit) and the +// product catalogue (for the price-rule product picker). Per-customer pricing +// itself is loaded client-side once a customer is selected. +export async function load({ fetch }) { + if (!hasStoredClientSession()) { + return { customers: [] as OrderingCustomer[], products: [] as CatalogueProduct[] }; + } + + try { + const [customers, products] = await Promise.all([ + api.orderingAdmin.customers(fetch), + api.orderingAdmin.products(fetch) + ]); + return { customers, products }; + } catch { + return { customers: [] as OrderingCustomer[], products: [] as CatalogueProduct[] }; + } +} diff --git a/frontend/src/routes/ordering/manage/products/+page.svelte b/frontend/src/routes/ordering/manage/products/+page.svelte new file mode 100644 index 0000000..f5b4dcd --- /dev/null +++ b/frontend/src/routes/ordering/manage/products/+page.svelte @@ -0,0 +1,118 @@ + + +
+
+

Catalogue ({products.length})

+ +
+ + + + {#each products as p (p.id)} + + + + + + + + + {/each} + +
NameSKUCategoryBase priceActive
{p.name}{#if p.requires_quote}quote{/if}{p.sku}{label(p.category)} saveProductPrice(p, e.currentTarget.value)} />{p.active ? 'Yes' : 'No'}
+
+ +{#if showNewProduct} + +{/if} diff --git a/frontend/src/routes/ordering/manage/products/+page.ts b/frontend/src/routes/ordering/manage/products/+page.ts new file mode 100644 index 0000000..8bc8afb --- /dev/null +++ b/frontend/src/routes/ordering/manage/products/+page.ts @@ -0,0 +1,17 @@ +import { hasStoredClientSession } from '$lib/session'; +import { api } from '$lib/api'; +import type { CatalogueProduct } from '$lib/types'; + +// Catalogue products. Access enforced by the family +layout.ts guard. +export async function load({ fetch }) { + if (!hasStoredClientSession()) { + return { products: [] as CatalogueProduct[] }; + } + + try { + const products = await api.orderingAdmin.products(fetch); + return { products }; + } catch { + return { products: [] as CatalogueProduct[] }; + } +} diff --git a/frontend/src/routes/ordering/manage/settings/+page.svelte b/frontend/src/routes/ordering/manage/settings/+page.svelte new file mode 100644 index 0000000..189c394 --- /dev/null +++ b/frontend/src/routes/ordering/manage/settings/+page.svelte @@ -0,0 +1,37 @@ + + +
+

Notification settings

+ {#if settings} +
+ + + + + +
+ {:else} +

Could not load settings.

+ {/if} +
diff --git a/frontend/src/routes/ordering/manage/settings/+page.ts b/frontend/src/routes/ordering/manage/settings/+page.ts new file mode 100644 index 0000000..deaff0f --- /dev/null +++ b/frontend/src/routes/ordering/manage/settings/+page.ts @@ -0,0 +1,17 @@ +import { hasStoredClientSession } from '$lib/session'; +import { api } from '$lib/api'; +import type { OrderingNotificationSettings } from '$lib/types'; + +// Notification settings. Access enforced by the family +layout.ts guard. +export async function load({ fetch }) { + if (!hasStoredClientSession()) { + return { settings: null as OrderingNotificationSettings | null }; + } + + try { + const settings = await api.orderingAdmin.notificationSettings(fetch); + return { settings }; + } catch { + return { settings: null as OrderingNotificationSettings | null }; + } +} diff --git a/frontend/src/routes/product-costing/+page.svelte b/frontend/src/routes/product-costing/+page.svelte index 6caa084..b8bb995 100644 --- a/frontend/src/routes/product-costing/+page.svelte +++ b/frontend/src/routes/product-costing/+page.svelte @@ -735,11 +735,34 @@ --costing-line: oklch(88% 0.014 145); --costing-line-strong: oklch(78% 0.02 145); --costing-warn: oklch(58% 0.14 72); + --costing-input-bg: oklch(99% 0.004 145); + --costing-warn-soft: oklch(95% 0.055 82); + --costing-warn-box: oklch(96% 0.045 84); + --costing-warn-border: oklch(84% 0.085 78); + --costing-warn-row: oklch(92% 0.08 81); display: grid; gap: 1.05rem; color: var(--costing-ink); } + /* Dark mode: remap the costing palette onto the neutral dark theme tokens so + the worksheet themes alongside the rest of the app instead of staying a + bright light card. The warn tints lean on the global warning tokens. */ + :global([data-theme='dark']) .costing-shell { + --costing-ink: var(--color-text-primary); + --costing-muted: var(--color-text-muted); + --costing-panel: var(--color-bg-surface); + --costing-soft: var(--color-bg-app); + --costing-line: var(--color-border); + --costing-line-strong: var(--color-border); + --costing-warn: var(--color-warning-text); + --costing-input-bg: var(--color-input-bg); + --costing-warn-soft: var(--color-warning-tint); + --costing-warn-box: var(--color-warning-tint); + --costing-warn-border: color-mix(in srgb, var(--color-warning) 45%, var(--color-border)); + --costing-warn-row: var(--color-warning); + } + .page-head, .health-strip, .workspace-grid, @@ -809,7 +832,7 @@ padding: 0.38rem 0.62rem; border-radius: 999px; color: var(--costing-warn); - background: oklch(95% 0.055 82); + background: var(--costing-warn-soft); font-size: 0.8rem; font-weight: 800; } @@ -881,7 +904,7 @@ } .health-card.warning { - background: color-mix(in srgb, oklch(91% 0.08 80) 34%, var(--costing-panel)); + background: color-mix(in srgb, var(--costing-warn-row) 34%, var(--costing-panel)); } .health-card strong { @@ -978,7 +1001,7 @@ padding: 0.55rem 0.65rem; border: 1px solid var(--costing-line-strong); border-radius: 0.66rem; - background: oklch(99% 0.004 145); + background: var(--costing-input-bg); color: var(--costing-ink); } @@ -995,7 +1018,7 @@ align-items: center; border: 1px solid var(--costing-line-strong); border-radius: 0.66rem; - background: oklch(99% 0.004 145); + background: var(--costing-input-bg); overflow: hidden; } @@ -1085,7 +1108,7 @@ } tbody tr.warn td { - background: color-mix(in srgb, oklch(93% 0.08 83) 45%, var(--costing-panel)); + background: color-mix(in srgb, var(--costing-warn-row) 45%, var(--costing-panel)); } tbody td { @@ -1166,7 +1189,7 @@ .status-pill.warning { color: var(--costing-warn); - background: oklch(95% 0.055 82); + background: var(--costing-warn-soft); } .skeleton-row td { @@ -1257,10 +1280,10 @@ align-items: flex-start; padding: 0.78rem; margin-bottom: 0.85rem; - border: 1px solid oklch(84% 0.085 78); + border: 1px solid var(--costing-warn-border); border-radius: 0.82rem; color: var(--costing-warn); - background: oklch(96% 0.045 84); + background: var(--costing-warn-box); font-size: 0.85rem; font-weight: 700; } diff --git a/frontend/src/routes/raw-materials/+page.svelte b/frontend/src/routes/raw-materials/+page.svelte index d87c600..d0b1812 100644 --- a/frontend/src/routes/raw-materials/+page.svelte +++ b/frontend/src/routes/raw-materials/+page.svelte @@ -617,7 +617,7 @@ } .eyebrow { - color: #7f8e85; + color: var(--color-text-muted); font-size: 0.78rem; font-weight: 600; letter-spacing: 0.08em; @@ -680,14 +680,14 @@ .feedback.success { color: var(--green-deep); - border-color: #d8ecdf; - background: #f6fcf8; + border-color: color-mix(in srgb, var(--color-success) 22%, var(--color-border)); + background: color-mix(in srgb, var(--color-success-tint) 55%, var(--color-bg-surface)); } .feedback.error { - color: #a03737; - border-color: #f0d9d9; - background: #fff8f8; + color: var(--color-error); + border-color: color-mix(in srgb, var(--color-error) 22%, var(--color-border)); + background: color-mix(in srgb, var(--color-error) 8%, var(--color-bg-surface)); } .metric-row, @@ -839,7 +839,7 @@ label { display: grid; gap: 0.35rem; - color: #53645b; + color: var(--color-text-secondary); font-size: 0.9rem; font-weight: 600; } @@ -860,7 +860,7 @@ padding: 0.85rem 1rem; border: none; border-radius: 0.9rem; - color: #fff; + color: var(--color-on-brand); background: var(--color-brand); box-shadow: none; font-weight: 600; @@ -955,13 +955,13 @@ } .material-icon.active { - color: #fff; + color: var(--color-on-brand); background: var(--color-brand); } .material-icon.muted { - color: #55685f; - background: #e9efeb; + color: var(--color-text-secondary); + background: var(--color-surface-hover); } .status-pill { @@ -981,8 +981,8 @@ } .status-pill.neutral { - color: #5a6c63; - background: #edf2ef; + color: var(--color-text-secondary); + background: var(--color-surface-hover); } .material-grid { diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index d3ba009..43656a5 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -3,12 +3,17 @@ import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte'; import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte'; import { clientSession } from '$lib/session'; + import { canEditThroughput } from '$lib/workspace-access'; import { toast } from '$lib/toast'; - import { CircleUserRound, LockKeyhole } from 'lucide-svelte'; + import type { ThroughputImportResult } from '$lib/types'; + import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte'; - type Section = 'profile' | 'security'; + type Section = 'profile' | 'security' | 'import'; let activeSection = $state
('profile'); + // Only operators who can edit throughput see (and can use) the import tool. + const canImportThroughput = $derived(canEditThroughput($clientSession)); + let name = $state($clientSession?.name ?? ''); let email = $state($clientSession?.email ?? ''); @@ -69,6 +74,90 @@ } } + // ── Throughput import ───────────────────────────────────────── + let importFile = $state(null); + let importing = $state(false); + let importResult = $state(null); + let importError = $state(''); + + function onImportFileChange(event: Event) { + const input = event.currentTarget as HTMLInputElement; + importFile = input.files?.[0] ?? null; + importResult = null; + importError = ''; + } + + async function runImport() { + if (!importFile) { + importError = 'Choose a CSV or spreadsheet file first.'; + return; + } + importing = true; + importError = ''; + importResult = null; + const tid = toast.loading('Importing entries…'); + try { + const result = await api.importThroughputEntries(importFile); + importResult = result; + toast.dismiss(tid); + if (result.entries_imported > 0) { + toast.success( + `Imported ${result.entries_imported} ${result.entries_imported === 1 ? 'entry' : 'entries'}` + ); + } else { + toast.error('No entries were imported. Check the file and try again.'); + } + } catch (err: unknown) { + toast.dismiss(tid); + const msg = err instanceof Error ? err.message : 'Import failed'; + importError = msg; + toast.error(msg); + } finally { + importing = false; + } + } + + // Build a small sample CSV in the browser so operators have a working header + // row to copy from. The backend matches these headers case-insensitively. + function downloadTemplate() { + const headers = [ + 'Date', + 'Product', + 'Item ID', + 'Quantity', + 'Type', + 'Bag Size', + 'Packed By', + 'For Order', + 'Job Number', + 'For Stock', + 'Stock Quantity', + 'Notes' + ]; + const example = [ + '2026-06-12', + 'Specialty Pigeon Breeder', + '', + '40', + 'bags', + '20', + 'Jane Doe', + 'yes', + 'JOB1234', + 'no', + '', + 'First run of the day' + ]; + const csv = `${headers.join(',')}\n${example.join(',')}\n`; + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'throughput-import-template.csv'; + link.click(); + URL.revokeObjectURL(url); + } + const initials = $derived( ($clientSession?.name ?? '') .split(' ') @@ -78,12 +167,13 @@ .toUpperCase() || '?' ); - const navItems: { id: Section; label: string; icon: typeof CircleUserRound }[] = [ + const navItems = $derived<{ id: Section; label: string; icon: typeof CircleUserRound }[]>([ { id: 'profile', label: 'Profile', icon: CircleUserRound }, { id: 'security', label: 'Security', icon: LockKeyhole }, - ]; + ...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []), + ]); - const railGroups = [{ items: navItems }]; + const railGroups = $derived([{ items: navItems }]); @@ -164,6 +254,92 @@
+ + {:else if activeSection === 'import' && canImportThroughput} +
+
+

Import throughput entries

+

Upload a CSV or Excel (.xlsx) file of packing runs. Each row is saved as a throughput entry.

+
+ +
+
+

Required columns

+

+ Your file needs a header row with at least Date, Product + and Quantity columns. These optional columns are also recognised: +

+
    +
  • Typebags or kg (inferred from bag size if omitted)
  • +
  • Bag Size — kg per bag (required when packing as bags)
  • +
  • Item ID — matches an existing product; otherwise matched by name
  • +
  • Packed By, Notes
  • +
  • For Order, Job Number, For Stock, Stock Quantity
  • +
+

+ Products that don't already exist are created automatically. Dates accept + YYYY-MM-DD or DD/MM/YYYY. +

+ +
+ +
+ + + {#if importError} +

{importError}

+ {/if} + + {#if importResult} +
+

+ Imported {importResult.entries_imported} + {importResult.entries_imported === 1 ? 'entry' : 'entries'}. +

+
    + {#if importResult.products_created > 0} +
  • {importResult.products_created} new product{importResult.products_created === 1 ? '' : 's'} created
  • + {/if} + {#if importResult.entries_skipped > 0} +
  • {importResult.entries_skipped} row{importResult.entries_skipped === 1 ? '' : 's'} skipped
  • + {/if} +
+ {#if importResult.errors.length > 0} +
+ {importResult.errors.length} issue{importResult.errors.length === 1 ? '' : 's'} to review +
    + {#each importResult.errors as err (err)} +
  • {err}
  • + {/each} +
+
+ {/if} +
+ {/if} + + +
+
+
{/if} @@ -288,11 +464,173 @@ cursor: not-allowed; } + /* ── Import ─────────────────────────────────────────────────── */ + + .import-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1.5rem; + padding: 1.5rem 1.75rem; + } + + .import-help h3 { + margin: 0 0 0.5rem; + font-size: 0.92rem; + font-weight: 700; + color: var(--text); + } + + .import-help p { + margin: 0 0 0.65rem; + font-size: 0.85rem; + line-height: 1.5; + color: var(--muted); + } + + .import-help ul { + margin: 0 0 0.65rem; + padding-left: 1.1rem; + display: grid; + gap: 0.3rem; + font-size: 0.84rem; + color: var(--muted); + } + + .import-help code { + padding: 0.05rem 0.32rem; + border-radius: 0.35rem; + background: var(--panel-soft); + border: 1px solid var(--line); + font-size: 0.8rem; + } + + .import-note { + font-size: 0.82rem; + } + + .btn-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0; + background: none; + border: none; + color: var(--color-brand); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + } + + .btn-link:hover { + text-decoration: underline; + } + + .import-control { + display: grid; + gap: 1rem; + align-content: start; + } + + .file-drop { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem 1.25rem; + border: 1.5px dashed var(--line); + border-radius: 0.75rem; + background: var(--panel-soft); + color: var(--muted); + cursor: pointer; + text-align: center; + transition: border-color 140ms ease, color 140ms ease; + } + + .file-drop:hover { + border-color: var(--color-brand); + color: var(--text); + } + + .file-drop.has-file { + border-style: solid; + border-color: var(--color-brand); + color: var(--text); + } + + .file-drop input { + display: none; + } + + .file-drop-label { + font-size: 0.88rem; + font-weight: 600; + word-break: break-all; + } + + .file-drop-size { + font-size: 0.78rem; + color: var(--muted); + } + + .form-error { + display: flex; + align-items: center; + gap: 0.4rem; + } + + .import-result { + padding: 0.85rem 1rem; + border: 1px solid color-mix(in srgb, var(--color-brand) 25%, transparent); + border-radius: 0.6rem; + background: color-mix(in srgb, var(--color-brand) 7%, transparent); + } + + .import-result-head { + margin: 0; + font-size: 0.9rem; + color: var(--text); + } + + .import-result-stats { + margin: 0.45rem 0 0; + padding-left: 1.1rem; + display: grid; + gap: 0.2rem; + font-size: 0.83rem; + color: var(--muted); + } + + .import-errors { + margin-top: 0.6rem; + font-size: 0.83rem; + color: var(--muted); + } + + .import-errors summary { + cursor: pointer; + font-weight: 600; + color: var(--text); + } + + .import-errors ul { + margin: 0.45rem 0 0; + padding-left: 1.1rem; + display: grid; + gap: 0.25rem; + max-height: 12rem; + overflow-y: auto; + } + /* ── Responsive ─────────────────────────────────────────────── */ @media (max-width: 720px) { .field-row { grid-template-columns: 1fr; } + + .import-body { + grid-template-columns: 1fr; + } } diff --git a/frontend/src/routes/throughput/+page.svelte b/frontend/src/routes/throughput/+page.svelte index f3ee7ab..505c408 100644 --- a/frontend/src/routes/throughput/+page.svelte +++ b/frontend/src/routes/throughput/+page.svelte @@ -6,17 +6,23 @@ ThroughputProduct, ThroughputQuantityType } from '$lib/types'; - import { ArrowUpDown, ChevronLeft, ChevronRight, History, Plus, TriangleAlert, Search, X } from 'lucide-svelte'; + import { ArrowUpDown, CalendarDays, CalendarRange, Carrot, ChevronLeft, ChevronRight, Gauge, History, Pencil, Plus, TrendingUp, Trash2, TriangleAlert, Search, Wheat, X } from 'lucide-svelte'; import { fade } from 'svelte/transition'; import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte'; import { formatDate as formatDisplayDate, formatLocaleNumber, toNum } from '$lib/format'; - let { data } = $props<{ data: { entries: ThroughputEntry[]; products: ThroughputProduct[] } }>(); + let { data } = $props<{ + data: { entries: ThroughputEntry[]; statsEntries: ThroughputEntry[]; products: ThroughputProduct[] }; + }>(); let entries = $state([]); let entriesInitialized = $state(false); + // Separate, unfiltered window (~6 weeks) that backs the hero cards so the + // "Find past entries" filters on the log never distort Today / This week. + let statsEntries = $state([]); + let statsInitialized = $state(false); const products = $derived(data.products ?? []); - type SortKey = 'date' | 'product' | 'packed' | 'staff' | 'destination' | 'notes'; + type SortKey = 'date' | 'product' | 'packed' | 'total' | 'staff' | 'destination' | 'notes'; type SortDirection = 'asc' | 'desc'; const PAGE_SIZE = 20; let sortKey = $state('date'); @@ -30,6 +36,28 @@ } }); + $effect(() => { + if (!statsInitialized) { + statsEntries = data.statsEntries ?? data.entries ?? []; + statsInitialized = true; + } + }); + + // Keep the hero-card dataset in sync as the operator adds, edits, or removes + // runs without needing a round-trip to reload the 6-week window. + function upsertStatsEntry(entry: ThroughputEntry) { + const idx = statsEntries.findIndex((e) => e.id === entry.id); + if (idx === -1) { + statsEntries = [entry, ...statsEntries]; + } else { + statsEntries = statsEntries.map((e) => (e.id === entry.id ? entry : e)); + } + } + + function removeStatsEntry(id: number) { + statsEntries = statsEntries.filter((e) => e.id !== id); + } + // A run is "going into stock" when the operator ticks the stock checkpoint. // Older imported rows pre-date that flag, so fall back to the notes marker. function isStockEntry(entry: ThroughputEntry): boolean { @@ -55,7 +83,7 @@ } // ── Inline "spreadsheet" add row ────────────────────────────── - const today = new Date().toISOString().slice(0, 10); + const today = toISODate(ausToday()); let nDate = $state(today); let nProductId = $state(''); let nQuantity = $state(''); @@ -72,6 +100,10 @@ let saving = $state(false); let addError = $state(''); let highlightId = $state(null); + // When set, the composer is editing an existing run rather than adding one. + let editingId = $state(null); + let composer = $state(null); + let deletingId = $state(null); // When both checkpoints are ticked the run is split, so we need to know how // much goes to stock (the rest belongs to the order). @@ -101,6 +133,88 @@ return bag === null ? null : qty * bag; }); + // Collapse the note field again, discarding anything typed. Lets the operator + // back out after opening "+ Add a note" without leaving a stray value behind. + function dismissNote() { + nNotes = ''; + showNote = false; + } + + function resetComposer() { + nProductId = ''; + nQuantity = ''; + nType = 'bags'; + nBagSize = ''; + nNotes = ''; + nForOrder = false; + nForStock = false; + nJobNumber = ''; + nStockQty = ''; + showNote = false; + } + + // Load an existing run back into the composer so it can be corrected in place. + function startEdit(entry: ThroughputEntry) { + editingId = entry.id; + addError = ''; + nDate = entry.production_date; + nProductId = entry.product_id != null ? String(entry.product_id) : ''; + nQuantity = String(entry.quantity); + nType = entry.quantity_type; + nBagSize = entry.bag_size != null ? String(entry.bag_size) : ''; + nStaff = entry.staff_name ?? ''; + nNotes = entry.notes ?? ''; + showNote = Boolean(entry.notes); + nForOrder = entry.for_order; + nForStock = entry.for_stock; + nJobNumber = entry.job_number ?? ''; + nStockQty = entry.stock_quantity != null ? String(entry.stock_quantity) : ''; + composer?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + function cancelEdit() { + editingId = null; + addError = ''; + resetComposer(); + } + + // Delete uses an in-app confirmation modal (not the browser's confirm) so the + // action stays inside the app's design system. `pendingDelete` holds the run + // awaiting confirmation. + let pendingDelete = $state(null); + let deleteDialog = $state(null); + + function requestDelete(entry: ThroughputEntry) { + pendingDelete = entry; + } + + function cancelDelete() { + pendingDelete = null; + } + + // Move focus into the dialog when it opens so Escape/keyboard work and screen + // readers land on it. + $effect(() => { + if (pendingDelete) deleteDialog?.focus(); + }); + + async function confirmDelete() { + const entry = pendingDelete; + if (!entry) return; + deletingId = entry.id; + try { + await api.deleteThroughputEntry(entry.id); + entries = entries.filter((e) => e.id !== entry.id); + removeStatsEntry(entry.id); + if (editingId === entry.id) cancelEdit(); + pendingDelete = null; + } catch (err) { + errorMessage = err instanceof Error ? err.message : 'Could not delete this entry. Please try again.'; + } finally { + deletingId = null; + } + } + async function addEntry() { addError = ''; if (!nProductId) { @@ -144,39 +258,42 @@ } } + const payload = { + production_date: nDate, + product_id: Number(nProductId), + product_name_snapshot: selectedNewProduct?.name ?? '', + bag_size: nType === 'bags' ? bag : null, + quantity: qty, + quantity_type: nType, + for_order: nForOrder, + for_stock: nForStock, + job_number: nForOrder ? job : null, + stock_quantity: stockQty, + staff_name: nStaff.trim() || null, + notes: nNotes.trim() || null + }; + saving = true; try { - const created = await api.createThroughputEntry({ - production_date: nDate, - product_id: Number(nProductId), - product_name_snapshot: selectedNewProduct?.name ?? '', - bag_size: nType === 'bags' ? bag : null, - quantity: qty, - quantity_type: nType, - for_order: nForOrder, - for_stock: nForStock, - job_number: nForOrder ? job : null, - stock_quantity: stockQty, - staff_name: nStaff.trim() || null, - notes: nNotes.trim() || null - }); - entries = [created, ...entries]; - page = 1; - highlightId = created.id; + if (editingId != null) { + const updated = await api.updateThroughputEntry(editingId, payload); + entries = entries.map((e) => (e.id === updated.id ? updated : e)); + upsertStatsEntry(updated); + highlightId = updated.id; + editingId = null; + } else { + const created = await api.createThroughputEntry(payload); + entries = [created, ...entries]; + upsertStatsEntry(created); + page = 1; + highlightId = created.id; + } + const flashed = highlightId; setTimeout(() => { - if (highlightId === created.id) highlightId = null; + if (highlightId === flashed) highlightId = null; }, 1800); // Reset for the next entry, keeping date and staff for fast repeats. - nProductId = ''; - nQuantity = ''; - nType = 'bags'; - nBagSize = ''; - nNotes = ''; - nForOrder = false; - nForStock = false; - nJobNumber = ''; - nStockQty = ''; - showNote = false; + resetComposer(); } catch (err) { addError = err instanceof Error ? err.message : 'Could not save. Please try again.'; } finally { @@ -232,18 +349,121 @@ applyFilters(); } - const totals = $derived.by(() => { - let totalKg = 0; - let totalBags = 0; - let stockCount = 0; - for (const entry of entries) { - totalKg += entry.calculated_kg || 0; - if (entry.quantity_type === 'bags') { - totalBags += entry.quantity || 0; + // ── Hero stats: today, this week (Mon–Sun), and the trailing 4-week average ── + function toISODate(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + // "Today" pinned to Australian Eastern time so the calendar date is correct + // regardless of the device's own timezone — UTC rolls over a day early during + // the AUS morning, which is why the card was showing the previous date. + // Returns a local-midnight Date carrying the AUS date parts, which is all the + // week/period maths below needs. + function ausToday(): Date { + const ymd = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Australia/Sydney', + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).format(new Date()); + const [y, m, d] = ymd.split('-').map(Number); + return new Date(y, m - 1, d); + } + + // Monday 00:00 of the week containing `d` (Mon–Sun weeks, local time). + function startOfWeekMonday(d: Date): Date { + const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const dow = (start.getDay() + 6) % 7; // 0 = Monday … 6 = Sunday + start.setDate(start.getDate() - dow); + return start; + } + + function addDays(d: Date, days: number): Date { + const next = new Date(d); + next.setDate(next.getDate() + days); + return next; + } + + const heroStats = $derived.by(() => { + const now = ausToday(); + const todayStr = toISODate(now); + const thisMonday = startOfWeekMonday(now); + // Index 0 = current week, 1–4 = the four completed weeks before it. + const weekStarts = [0, 1, 2, 3, 4].map((i) => addDays(thisMonday, -7 * i)); + const earliest = weekStarts[4]; + + let today = 0; + const weekTotals = [0, 0, 0, 0, 0]; + for (const entry of statsEntries) { + const kg = entry.calculated_kg || 0; + if (entry.production_date === todayStr) today += kg; + const [y, m, day] = entry.production_date.split('-').map(Number); + if (!y || !m || !day) continue; + const d = new Date(y, m - 1, day); + if (d < earliest) continue; + for (let i = 0; i < 5; i++) { + if (d >= weekStarts[i]) { + weekTotals[i] += kg; + break; + } } - if (isStockEntry(entry)) stockCount += 1; } - return { totalKg, totalBags, stockCount, count: entries.length }; + + const priorFour = weekTotals.slice(1); + const avgFourWeek = priorFour.reduce((sum, value) => sum + value, 0) / 4; + return { today, thisWeek: weekTotals[0], avgFourWeek }; + }); + + // "9 Jun – 15 Jun"-style range for the current Mon–Sun week, shown as the + // This week card's subtitle (AUS time). + const weekRangeLabel = $derived.by(() => { + const monday = startOfWeekMonday(ausToday()); + const sunday = addDays(monday, 6); + const fmt = (d: Date) => + formatDisplayDate(toISODate(d), { day: 'numeric', month: 'short' }, 'en-AU'); + return `${fmt(monday)} – ${fmt(sunday)}`; + }); + + // ── Customer split: Horse Mix (PHF Horsemix) vs Grain Mix (everyone else) ── + // The entry carries a product id; the customer lives on the product record. + const productClientById = $derived.by(() => { + const map = new Map(); + for (const p of products as ThroughputProduct[]) map.set(p.id, p.client_name); + return map; + }); + + // PHF Horsemix is the only "horse mix" customer; tolerate spelling variants + // ("PHF Horsemix" / "PHF Horse Mix(es)") by matching on the two key words. + function isHorseMixClient(name: string | null | undefined): boolean { + if (!name) return false; + const norm = name.toLowerCase(); + return norm.includes('phf') && norm.includes('horse'); + } + + // Rolling-window presets for the customer-mix cards. The stats dataset is + // loaded wide enough (12 weeks) that switching range only re-filters in the + // browser — no extra request — and stays in sync with inline edits. + const MIX_RANGES = [ + { key: '7d', label: '7 days', sub: 'last 7 days', days: 7 }, + { key: '4w', label: '4 weeks', sub: 'last 4 weeks', days: 28 }, + { key: '6w', label: '6 weeks', sub: 'last 6 weeks', days: 42 }, + { key: '12w', label: '12 weeks', sub: 'last 12 weeks', days: 84 } + ] as const; + let mixRangeKey = $state<(typeof MIX_RANGES)[number]['key']>('4w'); + const mixRange = $derived(MIX_RANGES.find((r) => r.key === mixRangeKey) ?? MIX_RANGES[1]); + + const mixTotals = $derived.by(() => { + const cutoff = toISODate(addDays(ausToday(), -(mixRange.days - 1))); + let horse = 0; + let grain = 0; + for (const entry of statsEntries) { + if (entry.production_date < cutoff) continue; + const kg = entry.calculated_kg || 0; + const client = entry.product_id != null ? productClientById.get(entry.product_id) ?? null : null; + if (isHorseMixClient(client)) horse += kg; + else grain += kg; + } + return { horse, grain }; }); function formatDate(value: string) { @@ -297,6 +517,8 @@ } else if (sortKey === 'packed') { result = (a.calculated_kg ?? 0) - (b.calculated_kg ?? 0); if (result === 0) result = (a.quantity ?? 0) - (b.quantity ?? 0); + } else if (sortKey === 'total') { + result = (a.calculated_kg ?? 0) - (b.calculated_kg ?? 0); } else if (sortKey === 'staff') { result = compareText(a.staff_name, b.staff_name); } else if (sortKey === 'destination') { @@ -323,30 +545,63 @@
+
+ +

Throughput Overview

+
+ {#each MIX_RANGES as range (range.key)} + + {/each} +
+
-
Entries logged
-
{formatNumber(totals.count)}
+
Today
+
{formatNumber(heroStats.today)} kg
+

{formatDate(today)}

-
Bags packed
-
{formatNumber(totals.totalBags)}
+
This week
+
{formatNumber(heroStats.thisWeek)} kg
+

{weekRangeLabel}

-
Total kilograms
-
{formatNumber(totals.totalKg)}
+
4-week average
+
{formatNumber(heroStats.avgFourWeek)} kg
+

Per week, last 4 weeks

+
+
+
+
+
Horse Mix
+
{formatNumber(mixTotals.horse)} kg
+

PHF Horsemix · {mixRange.sub}

+
+
+
Grain Mix
+
{formatNumber(mixTotals.grain)} kg
+

All other customers · {mixRange.sub}

-
+
-

Inline entry

-

Add a packing run

+

{editingId != null ? 'Edit packing run' : 'Add a packing run'}

- Open full form + {#if editingId != null} + + {/if}
{ e.preventDefault(); addEntry(); }}> @@ -399,52 +654,69 @@
Destination -
- - +
+ +
+ + {#if nForOrder} + + {/if} +
+
+ + {#if isSplit} + + {/if} +
- {#if nForOrder} - - {/if} - {#if isSplit} - - {/if}
{#if showNote} - +
+ + +
{:else} {/if} @@ -550,6 +822,10 @@ Packed + + Edit
{#if isLoading} @@ -591,6 +868,10 @@ {packedMain(entry)} {packedDetail(entry)} + + Total kg + {formatNumber(entry.calculated_kg)} kg + Packed by {entry.staff_name ?? '—'} @@ -605,6 +886,27 @@ >{dest.label} {#if dest.detail}{dest.detail}{/if} + + + + {#if entry.notes}

Note{entry.notes}

{/if} @@ -646,6 +948,40 @@
+{#if pendingDelete} + +{/if} + diff --git a/frontend/src/routes/throughput/+page.ts b/frontend/src/routes/throughput/+page.ts index c590961..0632c91 100644 --- a/frontend/src/routes/throughput/+page.ts +++ b/frontend/src/routes/throughput/+page.ts @@ -5,7 +5,7 @@ import { canOpenThroughput, getWorkspaceHomeHref } from '$lib/workspace-access'; export async function load({ fetch }) { if (!hasStoredClientSession()) { - return { entries: [], products: [] }; + return { entries: [], statsEntries: [], products: [] }; } const session = getStoredClientSession(); @@ -18,13 +18,22 @@ export async function load({ fetch }) { recentFrom.setDate(recentFrom.getDate() - 30); const dateFrom = recentFrom.toISOString().slice(0, 10); + // The overview cards are computed in the browser: the hero cards (today / + // this week / 4-week average) plus the customer-mix cards whose rolling range + // the operator can switch up to 12 weeks. Pull that widest window once so + // changing range is a pure re-filter with no extra request. + const statsFrom = new Date(); + statsFrom.setDate(statsFrom.getDate() - 84); + const statsDateFrom = statsFrom.toISOString().slice(0, 10); + try { - const [entries, products] = await Promise.all([ + const [entries, statsEntries, products] = await Promise.all([ api.throughputEntries({ date_from: dateFrom, limit: 200 }, fetch), + api.throughputEntries({ date_from: statsDateFrom, limit: 1000 }, fetch), api.throughputProducts(fetch) ]); - return { entries, products }; + return { entries, statsEntries, products }; } catch { - return { entries: [], products: [] }; + return { entries: [], statsEntries: [], products: [] }; } }