From 7db95e2027b9015a57cae914ddb7c8006b535c78 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Tue, 16 Jun 2026 14:43:17 +1200 Subject: [PATCH] v0.1.27 Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live. Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator). Fix: Security headers on all API responses (hardening) Add: New mix button available on the Mix Editor. Add: New ingredient button available on the Ingredient Editor --- .env.production.example | 7 + CLAUDE.MD | 5 + backend/app/api/editor.py | 111 ++++ backend/app/api/public_v1.py | 78 +++ backend/app/core/config.py | 8 + backend/app/main.py | 8 + backend/app/schemas/editor.py | 57 ++ .../app/services/mix_calculator_service.py | 90 +++ backend/tests/test_editor_formula.py | 102 ++++ backend/tests/test_powerbi_v1.py | 115 ++++ backend/tests/test_security_headers.py | 108 ++++ deploy/Deploy.ps1 | 15 + deploy/nginx/clients.lean-101.conf | 12 +- docker-compose.production.yml | 3 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/lib/api.ts | 17 + frontend/src/lib/changelog.ts | 13 +- frontend/src/lib/components/AppShell.svelte | 191 ++++-- .../components/ClientAccessWorkspace.svelte | 15 +- .../lib/components/WorkspaceAppsFab.svelte | 164 ++++++ .../app-shell/WorkspaceQuickAccess.svelte | 161 ------ .../app-shell/WorkspaceSearchPalette.svelte | 201 ------- .../app-shell/WorkspaceTabletNav.svelte | 23 +- .../src/lib/components/mixes/MixEditor.svelte | 16 +- .../navigation/ClientPrimaryRail.svelte | 271 ++++++++- .../components/navigation/ClientTopbar.svelte | 151 ++--- .../navigation/WorkspacePageHeader.svelte | 89 +++ .../navigation/WorkspaceSearchField.svelte | 393 +++++++++++++ .../navigation/WorkspaceSearchTrigger.svelte | 85 --- .../ordering/CustomerProductVisibility.svelte | 314 ++++++++++ .../ordering/CustomerWorkspace.svelte | 546 ++++++++++++++++++ .../throughput/ThroughputHistory.svelte | 2 +- .../src/lib/navigation/client-navigation.ts | 110 +++- frontend/src/lib/ordering/manage.css | 216 +++++++ frontend/src/lib/types.ts | 35 ++ .../src/routes/client-access/+page.svelte | 10 - frontend/src/routes/editor/+page.svelte | 353 +++++++++-- frontend/src/routes/ordering/+page.svelte | 11 - .../src/routes/ordering/manage/+layout.svelte | 14 - .../src/routes/ordering/manage/+page.svelte | 241 +++++--- .../ordering/manage/customers/+page.svelte | 262 ++++----- .../ordering/manage/products/+page.svelte | 176 ++++-- .../src/routes/product-costing/+page.svelte | 44 +- .../src/routes/throughput/add/+page.svelte | 5 - frontend/static/hunter-logo-sidebar.png | Bin 0 -> 82642 bytes 46 files changed, 3805 insertions(+), 1049 deletions(-) create mode 100644 backend/app/api/public_v1.py create mode 100644 backend/tests/test_editor_formula.py create mode 100644 backend/tests/test_powerbi_v1.py create mode 100644 backend/tests/test_security_headers.py create mode 100644 frontend/src/lib/components/WorkspaceAppsFab.svelte delete mode 100644 frontend/src/lib/components/app-shell/WorkspaceQuickAccess.svelte delete mode 100644 frontend/src/lib/components/app-shell/WorkspaceSearchPalette.svelte create mode 100644 frontend/src/lib/components/navigation/WorkspacePageHeader.svelte create mode 100644 frontend/src/lib/components/navigation/WorkspaceSearchField.svelte delete mode 100644 frontend/src/lib/components/navigation/WorkspaceSearchTrigger.svelte create mode 100644 frontend/src/lib/components/ordering/CustomerProductVisibility.svelte create mode 100644 frontend/src/lib/components/ordering/CustomerWorkspace.svelte create mode 100644 frontend/static/hunter-logo-sidebar.png diff --git a/.env.production.example b/.env.production.example index 5238174..b150ad9 100644 --- a/.env.production.example +++ b/.env.production.example @@ -33,5 +33,12 @@ LOGIN_RATE_LIMIT_ATTEMPTS=8 LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 DOCS_ENABLED=false +# Read-only Power BI / external data API at /api/v1. Set a long random key to +# enable it; leave blank to disable the API entirely. Power BI sends this as an +# "X-API-Key" header (or "?api_key=" query parameter). +POWERBI_API_KEY=V7BI59yhRBF7VMiPNfgmqPxrsPuNuPFJ +# Tenant the Power BI API reads from. Defaults to CLIENT_TENANT_ID. +POWERBI_TENANT_ID= + PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false diff --git a/CLAUDE.MD b/CLAUDE.MD index 0f7a4a1..256be03 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -1,5 +1,10 @@ ## Repository operations +### RUles for Svelte +If a block has its own UI + state + behaviour, make it a component. +If logic is reused or long, move it to a .ts utility file. +If CSS is over 300–500 lines, split components. + ### Dependencies Current app dependency entry points: diff --git a/backend/app/api/editor.py b/backend/app/api/editor.py index e83c56c..eca13db 100644 --- a/backend/app/api/editor.py +++ b/backend/app/api/editor.py @@ -13,6 +13,8 @@ from app.schemas.editor import ( EditorIngredientRow, EditorIngredientUpdate, EditorMixFormulaRead, + EditorMixCreate, + EditorMixFormulaReplace, EditorMixIngredientCreate, EditorMixIngredientUpdate, EditorMixRow, @@ -22,9 +24,11 @@ from app.schemas.editor import ( EditorProductIngredientUpdate, EditorProductRow, EditorProductUpdate, + EditorResolvedMixFormula, ) from app.services.client_access_service import has_access_level from app.services.costing_engine import calculate_raw_material_cost, get_active_price +from app.services.mix_calculator_service import resolve_editor_mix_formula, resolve_representative_product router = APIRouter(prefix="/api/editor", tags=["editor"]) @@ -250,6 +254,25 @@ def list_editor_mixes( ] +@router.post("/mixes", response_model=EditorMixRow, status_code=201) +def create_editor_mix( + payload: EditorMixCreate, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + mix = Mix( + tenant_id=session.tenant_id or "", + client_name=payload.client_name.strip(), + name=payload.name.strip(), + notes=payload.notes, + ) + db.add(mix) + db.commit() + db.refresh(mix) + # A brand-new mix has no products yet, so it reads as Inactive (no visible products). + return _serialize_mix_row(mix, visible_count=0, product_count=0) + + @router.patch("/mixes/{mix_id}", response_model=EditorMixRow) def update_editor_mix( mix_id: int, @@ -377,6 +400,94 @@ def delete_editor_mix_ingredient( return _serialize_mix_formula(mix) +@router.get("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula) +def get_editor_mix_resolved_formula( + mix_id: int, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + """The mix formula as the Mix Calculator reads it (product-first resolution). + + This is what the Mix Editor displays, so the two surfaces show identical + ingredients and quantities. See `resolve_editor_mix_formula`. + """ + tenant_id = session.tenant_id or "" + mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id) + if mix is None: + raise HTTPException(status_code=404, detail="Mix not found") + return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix) + + +@router.put("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula) +def replace_editor_mix_formula( + mix_id: int, + payload: EditorMixFormulaReplace, + session: AuthSession = Depends(_require_editor_session), + db: Session = Depends(get_db), +): + """Replace a mix's whole formula in one save. + + Writes back to the *same source* the Mix Calculator reads: the representative + product's own formula (`ProductIngredient`) when it has one, otherwise the + shared mix master (`MixIngredient`). Either way the calculator immediately + reflects the edit. + """ + tenant_id = session.tenant_id or "" + mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id) + if mix is None: + raise HTTPException(status_code=404, detail="Mix not found") + + raw_ids = [row.raw_material_id for row in payload.rows] + if len(set(raw_ids)) != len(raw_ids): + raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix") + existing_ids = set( + db.scalars( + select(RawMaterial.id).where(RawMaterial.tenant_id == tenant_id, RawMaterial.id.in_(raw_ids)) + ).all() + ) + missing = [raw_id for raw_id in raw_ids if raw_id not in existing_ids] + if missing: + raise HTTPException(status_code=404, detail="Raw material not found") + + product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix_id) + if product is not None and product.ingredients: + # Replace the representative product's own formula. + for ingredient in list(product.ingredients): + db.delete(ingredient) + db.flush() + for sort_order, row in enumerate(payload.rows, start=1): + db.add( + ProductIngredient( + tenant_id=tenant_id, + product_id=product.id, + raw_material_id=row.raw_material_id, + quantity_kg=row.quantity_kg, + sort_order=sort_order, + notes=row.notes, + ) + ) + else: + # No product-specific formula in play: edit the shared mix master, which + # is what the calculator falls back to for this mix. + for ingredient in list(mix.ingredients): + db.delete(ingredient) + db.flush() + for row in payload.rows: + db.add( + MixIngredient( + tenant_id=tenant_id, + mix_id=mix.id, + raw_material_id=row.raw_material_id, + quantity_kg=row.quantity_kg, + notes=row.notes, + ) + ) + + db.commit() + mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id) + return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix) + + @router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead) def get_editor_product_ingredients( product_id: int, diff --git a/backend/app/api/public_v1.py b/backend/app/api/public_v1.py new file mode 100644 index 0000000..454b386 --- /dev/null +++ b/backend/app/api/public_v1.py @@ -0,0 +1,78 @@ +"""Read-only external data API (`/api/v1`). + +A deliberately simple, API-key authenticated surface for Power BI (and any other +external reporting tool). It is intentionally separate from the cookie/JWT +session model used by the operator frontend: external tools cannot hold a +browser session, so they present a single static key instead. + +Authentication: send the key either as an ``X-API-Key`` request header or an +``api_key`` query-string parameter (Power BI's Web connector supports both). +The key is configured via the ``POWERBI_API_KEY`` environment variable; when it +is blank the whole API is disabled and every request returns 503. +""" + +from __future__ import annotations + +import secrets +from datetime import date + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.security_logging import log_security_event +from app.db.session import get_db +from app.models.throughput import ProductionThroughput +from app.services.throughput_service import serialize_entry + +router = APIRouter(prefix="/api/v1", tags=["public-v1"]) + +_API_KEY_HEADER = "X-API-Key" + + +def require_powerbi_api_key(request: Request) -> str: + """Authorize an external request via the static Power BI API key. + + Returns the tenant the caller may read. Raises 503 when the API is not + configured, or 401 when the key is missing/incorrect. + """ + configured = settings.powerbi_api_key + if not configured: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="The data API is not configured.", + ) + + presented = request.headers.get(_API_KEY_HEADER) or request.query_params.get("api_key") or "" + # Constant-time comparison so the endpoint does not leak key length/contents + # through response timing. + if not presented or not secrets.compare_digest(presented, configured): + log_security_event("authz.denied", role="powerbi", reason="invalid_api_key") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key") + + return settings.powerbi_tenant_id + + +@router.get("/throughput") +def list_throughput( + date_from: date | None = Query(default=None, description="Only entries on/after this production date (YYYY-MM-DD)."), + date_to: date | None = Query(default=None, description="Only entries on/before this production date (YYYY-MM-DD)."), + limit: int = Query(default=5000, ge=1, le=50000), + tenant_id: str = Depends(require_powerbi_api_key), + db: Session = Depends(get_db), +): + """Flat list of production throughput entries for Power BI. + + One row per packing run, oldest first so incremental refreshes append + naturally. Each row carries the same fields the operator UI shows + (date, product, bag size, quantity, calculated kg, QA flags, staff, notes). + """ + stmt = select(ProductionThroughput).where(ProductionThroughput.tenant_id == tenant_id) + if date_from is not None: + stmt = stmt.where(ProductionThroughput.production_date >= date_from) + if date_to is not None: + stmt = stmt.where(ProductionThroughput.production_date <= date_to) + stmt = stmt.order_by(ProductionThroughput.production_date.asc(), ProductionThroughput.id.asc()).limit(limit) + + return [serialize_entry(entry) for entry in db.scalars(stmt).all()] diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7b80cb2..b3bc67f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -58,6 +58,12 @@ class Settings: login_rate_limit_window_seconds: int trusted_hosts: tuple[str, ...] docs_enabled: bool + # Static API key for the read-only Power BI / external data API (`/api/v1`). + # Blank disables the API entirely (every request returns 503). + powerbi_api_key: str + # Tenant the Power BI API reads from. Defaults to the costing client tenant + # (where internal staff store throughput), so a single key serves Irwin. + powerbi_tenant_id: str @classmethod def from_env(cls) -> "Settings": @@ -98,6 +104,8 @@ class Settings: login_rate_limit_window_seconds=int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")), trusted_hosts=_parse_csv_env(os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver")), docs_enabled=_env_flag("DOCS_ENABLED", default=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() != "production"), + powerbi_api_key=os.getenv("POWERBI_API_KEY", "").strip(), + powerbi_tenant_id=os.getenv("POWERBI_TENANT_ID", os.getenv("CLIENT_TENANT_ID", "hunter-premium-produce")).strip(), ) settings._validate() return settings diff --git a/backend/app/main.py b/backend/app/main.py index 830b16d..15384ff 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,6 +30,7 @@ from app.api.ordering_admin import router as ordering_admin_router from app.api.powerbi import router as powerbi_router from app.api.product_costing import router as product_costing_router from app.api.products import router as products_router +from app.api.public_v1 import router as public_v1_router from app.api.raw_materials import router as raw_materials_router from app.api.scenarios import router as scenarios_router from app.api.throughput import router as throughput_router @@ -209,6 +210,7 @@ app.include_router(throughput_router) app.include_router(ordering_router) app.include_router(ordering_admin_router) app.include_router(powerbi_router) +app.include_router(public_v1_router) @app.middleware("http") @@ -270,6 +272,11 @@ async def enforce_request_limits_and_csrf(request: Request, call_next): "script-src 'self'; " "font-src 'self' data:; " "connect-src 'self'; " + # PDF previews/printing load a same-origin blob: URL into an iframe. + # Without an explicit frame-src/child-src these fall back to default-src + # ('self'), which blocks blob: and breaks the in-app print dialog. + "frame-src 'self' blob:; " + "child-src 'self' blob:; " "frame-ancestors 'self'; " "base-uri 'self'; " "form-action 'self'" @@ -315,6 +322,7 @@ def root(): "scenarios": "/api/scenarios", "operations_throughput": "/api/throughput", "client_access": "/api/client-access", + "powerbi_throughput": "/api/v1/throughput", "docs": "/docs", }, } diff --git a/backend/app/schemas/editor.py b/backend/app/schemas/editor.py index 23f5ca5..c0b339a 100644 --- a/backend/app/schemas/editor.py +++ b/backend/app/schemas/editor.py @@ -32,6 +32,14 @@ class EditorProductUpdate(BaseModel): notes: str | None = Field(default=None, max_length=2000) +class EditorMixCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + client_name: str = Field(min_length=1, max_length=255) + name: str = Field(min_length=1, max_length=255) + notes: str | None = Field(default=None, max_length=2000) + + class EditorMixUpdate(BaseModel): model_config = ConfigDict(extra="forbid") @@ -71,6 +79,55 @@ class EditorMixFormulaRead(BaseModel): total_kg: float +class EditorResolvedMixIngredient(BaseModel): + raw_material_id: int + raw_material_name: str + quantity_kg: float + # This row's share of the mix total, matching the Mix Calculator. + mix_percentage: float + unit: str + notes: str | None + + +class EditorResolvedMixFormula(BaseModel): + """A mix formula resolved the way the Mix Calculator reads it. + + `source` is `product` when the numbers come from a representative product's + own formula, or `mix` when they come from the shared mix master fallback. + The PUT endpoint writes back to whichever source produced these rows. + """ + + id: int + tenant_id: str + client_name: str + name: str + source: str + product_id: int | None + ingredients: list[EditorResolvedMixIngredient] + total_kg: float + + +class EditorMixFormulaRowInput(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 EditorMixFormulaReplace(BaseModel): + """Full replacement of a mix's formula in one save. + + The frontend keeps kilograms as the canonical value (percentages are an + entry aid that resolve back to kg against the total), so the API only needs + the resolved kg per row. + """ + + model_config = ConfigDict(extra="forbid") + + rows: list[EditorMixFormulaRowInput] = Field(min_length=1) + + class EditorMixIngredientCreate(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/backend/app/services/mix_calculator_service.py b/backend/app/services/mix_calculator_service.py index 05c1ffa..8bc933e 100644 --- a/backend/app/services/mix_calculator_service.py +++ b/backend/app/services/mix_calculator_service.py @@ -93,6 +93,96 @@ def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int] ) +def resolve_representative_product(db: Session, *, tenant_id: str, mix_id: int) -> Product | None: + """The single product the Mix Calculator surfaces for a given mix. + + The calculator lists one representative product per (client, mix) and reads + its formula. The Mix Editor reuses this so it edits exactly what the + calculator shows. Preference order mirrors `build_mix_calculator_options`: + visible products that already have a product-specific formula, ranked by + `_mix_calculator_option_rank`; then any visible product; then any product. + """ + products = db.scalars( + select(Product) + .where(Product.tenant_id == tenant_id, Product.mix_id == mix_id) + .options( + selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material), + selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material), + ) + ).all() + if not products: + return None + with_formula = [product for product in products if product.visible and product.ingredients] + pool = with_formula or [product for product in products if product.visible] or list(products) + return min(pool, key=_mix_calculator_option_rank) + + +def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict: + """Resolve a mix's formula the way the calculator does, for the editor. + + Returns the resolved ingredient rows (with each row's share of the total as + `mix_percentage`), the total kg, and where the formula lives: + `source='product'` (a representative product's own formula) or `source='mix'` + (the shared mix master fallback). `product_id` names the product that owns the + formula when `source='product'`. The save path writes back to that same source. + """ + product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix.id) + if product is not None and product.ingredients: + rows, total_kg = _resolved_formula_rows(product) + # Carry each ingredient's note through so saving doesn't wipe it. + notes_by_raw_material = { + ingredient.raw_material_id: ingredient.notes for ingredient in product.ingredients + } + for row in rows: + row["notes"] = notes_by_raw_material.get(row["raw_material_id"]) + source = "product" + product_id = product.id + else: + # No product-specific formula: the calculator reads the shared mix master, + # so the editor shows and edits that. + rows = [ + { + "raw_material_id": ingredient.raw_material_id, + "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", + "sort_order": index, + "notes": ingredient.notes, + } + for index, ingredient in enumerate( + sorted(mix.ingredients, key=lambda item: item.raw_material.name if item.raw_material else ""), + start=1, + ) + ] + total_kg = round(sum(row["quantity_kg"] for row in rows), 4) + source = "mix" + product_id = product.id if product is not None else None + + ingredients = [ + { + "raw_material_id": row["raw_material_id"], + "raw_material_name": row["raw_material_name"], + "quantity_kg": round(row["quantity_kg"], 4), + "mix_percentage": round((row["quantity_kg"] / total_kg) * 100, 4) if total_kg > 0 else 0.0, + "unit": row["unit"], + "notes": row.get("notes"), + } + for row in rows + ] + return { + "id": mix.id, + "tenant_id": mix.tenant_id, + "client_name": mix.client_name, + "name": mix.name, + "source": source, + "product_id": product_id, + "ingredients": ingredients, + "total_kg": total_kg, + } + + def calculate_mix_calculator_preview( db: Session, *, diff --git a/backend/tests/test_editor_formula.py b/backend/tests/test_editor_formula.py new file mode 100644 index 0000000..b01c5bf --- /dev/null +++ b/backend/tests/test_editor_formula.py @@ -0,0 +1,102 @@ +"""The Mix Editor must resolve and edit the SAME formula the Mix Calculator reads. + +These cover `resolve_editor_mix_formula` / `resolve_representative_product`: a mix +whose product carries its own formula resolves to that product (not the shared +mix master), percentages are computed against the total, and the representative +product is chosen the way the calculator chooses it. +""" +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.db.session import Base +from app.models.mix import Mix, MixIngredient +from app.models.product import Product, ProductIngredient +from app.models.raw_material import RawMaterial +from app.services.mix_calculator_service import ( + resolve_editor_mix_formula, + resolve_representative_product, +) + +TENANT = "hunter-premium-produce" + + +def _session() -> Session: + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, expire_on_commit=False)() + + +def _raw(db: Session, name: str) -> RawMaterial: + material = RawMaterial(tenant_id=TENANT, name=name, unit_of_measure="kg", kg_per_unit=1, status="active") + db.add(material) + db.flush() + return material + + +def test_resolves_product_formula_not_mix_master(): + db = _session() + bayley = _raw(db, "Bayley") + filler = _raw(db, "Filler") + + mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Pigeon Mix") + db.add(mix) + db.flush() + + # Shared mix master says something different from the product formula. + db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=bayley.id, quantity_kg=100)) + db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=filler.id, quantity_kg=100)) + + product = Product(tenant_id=TENANT, client_name="Hunter", name="Pigeon 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True) + db.add(product) + db.flush() + # The calculator's real numbers live here: 787.5 / 1320.41 ~ 59.6%. + db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=787.5, sort_order=1)) + db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=532.91, sort_order=2)) + db.commit() + + formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix) + + assert formula["source"] == "product" + assert formula["product_id"] == product.id + assert formula["total_kg"] == 1320.41 + by_name = {row["raw_material_name"]: row for row in formula["ingredients"]} + assert by_name["Bayley"]["quantity_kg"] == 787.5 + # Percentage matches the worked example (787.5 / 1320.41 * 100). + assert abs(by_name["Bayley"]["mix_percentage"] - 59.6406) < 0.001 + + +def test_falls_back_to_mix_master_when_no_product_formula(): + db = _session() + maize = _raw(db, "Maize") + mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix") + db.add(mix) + db.flush() + db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50)) + db.commit() + + formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix) + assert formula["source"] == "mix" + assert formula["total_kg"] == 50 + assert formula["ingredients"][0]["mix_percentage"] == 100.0 + + +def test_representative_product_prefers_20kg_bag(): + db = _session() + maize = _raw(db, "Maize") + mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Dual Mix") + db.add(mix) + db.flush() + + bulka = Product(tenant_id=TENANT, client_name="Hunter", name="Dual Bulka", mix_id=mix.id, unit_of_measure="500kg bulka", visible=True) + bag = Product(tenant_id=TENANT, client_name="Hunter", name="Dual 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True) + db.add_all([bulka, bag]) + db.flush() + for product in (bulka, bag): + db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=maize.id, quantity_kg=20, sort_order=1)) + db.commit() + + representative = resolve_representative_product(db, tenant_id=TENANT, mix_id=mix.id) + assert representative is not None + assert representative.unit_of_measure == "20kg bag" diff --git a/backend/tests/test_powerbi_v1.py b/backend/tests/test_powerbi_v1.py new file mode 100644 index 0000000..6e245b6 --- /dev/null +++ b/backend/tests/test_powerbi_v1.py @@ -0,0 +1,115 @@ +"""Tests for the read-only external data API (`/api/v1`) used by Power BI. + +Drives the real FastAPI app via TestClient against an in-memory database, and +swaps the configured API key in by replacing the module-level `settings` object +(the real one is a frozen dataclass). +""" +from __future__ import annotations + +from dataclasses import replace +from datetime import date + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api import public_v1 +from app.core.config import settings +from app.db.session import Base, get_db +from app.main import app +from app.models.throughput import ProductionThroughput + +API_KEY = "test-powerbi-key" +TENANT = "hunter-premium-produce" + + +def _seed_entry(db, *, tenant_id: str, product: str, quantity: float, when: date) -> None: + db.add( + ProductionThroughput( + tenant_id=tenant_id, + production_date=when, + product_name_snapshot=product, + bag_size=20, + quantity=quantity, + quantity_type="bags", + calculated_kg=quantity * 20, + created_by="test", + ) + ) + + +@pytest.fixture() +def db_factory(): + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + def override_get_db(): + db = TestingSession() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + yield TestingSession + app.dependency_overrides.clear() + + +def _make_client(monkeypatch, *, api_key: str) -> TestClient: + # Replace the whole settings object the router reads (frozen dataclass). + monkeypatch.setattr(public_v1, "settings", replace(settings, powerbi_api_key=api_key, powerbi_tenant_id=TENANT)) + return TestClient(app) + + +def test_disabled_when_key_unset(monkeypatch, db_factory): + client = _make_client(monkeypatch, api_key="") + response = client.get("/api/v1/throughput", headers={"X-API-Key": "anything"}) + assert response.status_code == 503 + + +def test_rejects_missing_and_wrong_key(monkeypatch, db_factory): + client = _make_client(monkeypatch, api_key=API_KEY) + assert client.get("/api/v1/throughput").status_code == 401 + assert client.get("/api/v1/throughput", headers={"X-API-Key": "nope"}).status_code == 401 + + +def test_returns_entries_for_tenant(monkeypatch, db_factory): + db = db_factory() + _seed_entry(db, tenant_id=TENANT, product="Maize", quantity=10, when=date(2026, 1, 5)) + _seed_entry(db, tenant_id=TENANT, product="Barley", quantity=5, when=date(2026, 1, 6)) + # An entry in another tenant must never leak through. + _seed_entry(db, tenant_id="someone-else", product="Secret", quantity=99, when=date(2026, 1, 7)) + db.commit() + db.close() + + client = _make_client(monkeypatch, api_key=API_KEY) + + # Header auth. + response = client.get("/api/v1/throughput", headers={"X-API-Key": API_KEY}) + assert response.status_code == 200 + rows = response.json() + names = [row["product_name_snapshot"] for row in rows] + assert names == ["Maize", "Barley"] # oldest first, other tenant excluded + assert rows[0]["calculated_kg"] == 200.0 + + # Query-string auth works too (Power BI Web connector convenience). + assert client.get(f"/api/v1/throughput?api_key={API_KEY}").status_code == 200 + + +def test_date_filter(monkeypatch, db_factory): + db = db_factory() + _seed_entry(db, tenant_id=TENANT, product="Old", quantity=1, when=date(2026, 1, 1)) + _seed_entry(db, tenant_id=TENANT, product="New", quantity=1, when=date(2026, 2, 1)) + db.commit() + db.close() + + client = _make_client(monkeypatch, api_key=API_KEY) + response = client.get("/api/v1/throughput?date_from=2026-01-15", headers={"X-API-Key": API_KEY}) + assert [row["product_name_snapshot"] for row in response.json()] == ["New"] diff --git a/backend/tests/test_security_headers.py b/backend/tests/test_security_headers.py new file mode 100644 index 0000000..712f55a --- /dev/null +++ b/backend/tests/test_security_headers.py @@ -0,0 +1,108 @@ +"""Regression guards for the print/PDF Content-Security-Policy. + +The in-app print dialog loads a generated PDF as a same-origin ``blob:`` URL into +an iframe and calls ``contentWindow.print()``. If the CSP omits ``frame-src`` / +``child-src`` for ``blob:`` the directive falls back to ``default-src 'self'``, +which silently blocks the frame and breaks printing for every user (regardless of +role). These tests pin the policy on both layers that emit it: + +* the FastAPI security middleware (covers every API response), and +* the production nginx config (the source of the *document* CSP that actually + governs ``frame-src`` in the browser). +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.core.security import issue_token +from app.main import app + +REPO_ROOT = Path(__file__).resolve().parents[2] +NGINX_CONFIGS = [REPO_ROOT / "deploy" / "nginx" / "clients.lean-101.conf"] + +# Directives the print flow depends on. blob: must be framable, and that must not +# come at the cost of dropping the same-origin baseline. +REQUIRED_FRAME_SOURCES = {"'self'", "blob:"} + + +def _parse_csp(header: str) -> dict[str, set[str]]: + """Parse a CSP header string into ``{directive: {sources}}``.""" + directives: dict[str, set[str]] = {} + for part in header.split(";"): + tokens = part.split() + if not tokens: + continue + directives[tokens[0].lower()] = set(tokens[1:]) + return directives + + +def _assert_blob_framing(header: str) -> None: + csp = _parse_csp(header) + # frame-src must exist and allow self + blob (no falling back to default-src). + assert "frame-src" in csp, f"frame-src missing from CSP: {header!r}" + assert REQUIRED_FRAME_SOURCES <= csp["frame-src"], ( + f"frame-src must allow {REQUIRED_FRAME_SOURCES}, got {csp['frame-src']}" + ) + # child-src is the Safari fallback for frame-src; keep it aligned. + assert "child-src" in csp, f"child-src missing from CSP: {header!r}" + assert "blob:" in csp["child-src"], f"child-src must allow blob:, got {csp['child-src']}" + # We only widened framing: the same-origin default must stay intact. + assert csp.get("default-src") == {"'self'"}, f"default-src weakened: {csp.get('default-src')}" + + +# --- Backend middleware policy ------------------------------------------------ + + +@pytest.fixture() +def client() -> TestClient: + with TestClient(app) as test_client: + yield test_client + + +def test_backend_csp_allows_blob_frames(client: TestClient) -> None: + response = client.get("/health") + assert "content-security-policy" in response.headers + _assert_blob_framing(response.headers["content-security-policy"]) + + +def test_backend_csp_present_for_all_users(client: TestClient) -> None: + """The policy is identical for anonymous, authenticated, and rejected (401) + requests, so printing can never depend on who is signed in.""" + admin_token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"}) + + responses = [ + client.get("/health"), # anonymous + client.get("/api/access/me"), # the endpoint that 401s for warehouse users + client.get("/api/access/me", headers={"Authorization": f"Bearer {admin_token}"}), + ] + + policies = set() + for response in responses: + header = response.headers.get("content-security-policy") + assert header is not None, f"CSP missing on {response.request.url} ({response.status_code})" + _assert_blob_framing(header) + policies.add(header) + + assert len(policies) == 1, "CSP must not vary by authentication state" + + +# --- Production document policy (nginx) --------------------------------------- + + +def test_nginx_csp_allows_blob_frames() -> None: + """Every CSP the production nginx emits must allow blob framing. This guards + the *document* policy, which is what the browser enforces for the print iframe.""" + csp_line = re.compile(r'Content-Security-Policy\s+"([^"]+)"', re.IGNORECASE) + + for config_path in NGINX_CONFIGS: + assert config_path.exists(), f"missing nginx config: {config_path}" + text = config_path.read_text(encoding="utf-8") + policies = csp_line.findall(text) + assert policies, f"no Content-Security-Policy header found in {config_path}" + for policy in policies: + _assert_blob_framing(policy) diff --git a/deploy/Deploy.ps1 b/deploy/Deploy.ps1 index adf08df..5594e1d 100644 --- a/deploy/Deploy.ps1 +++ b/deploy/Deploy.ps1 @@ -458,6 +458,21 @@ fi throw } + # ── Reload nginx config ─────────────────────────────────────────────────── + # The nginx config is a bind-mounted file. `docker compose up` only recreates + # a service when its definition changes, not when a mounted file's contents + # change, so a running nginx keeps serving the config it loaded at start. Force + # a reload so edits to clients.lean-101.conf (routing, security headers/CSP) + # actually take effect on every deploy. Non-fatal: stacks without an nginx + # service simply skip this. + Write-Step "Reloading nginx to apply config changes" + $nginxReload = "cd '$RemotePath' && docker compose $ComposeArgs exec -T nginx nginx -t && docker compose $ComposeArgs exec -T nginx nginx -s reload" + if ((Try-Ssh $nginxReload) -eq 0) { + Write-Ok "nginx reloaded" + } else { + Write-Warn "Skipped nginx reload (no nginx service, or config test failed)" + } + # ── Health check ──────────────────────────────────────────────────────────── Write-Step "Waiting for backend health check ($BackendContainer)" $healthScript = @" diff --git a/deploy/nginx/clients.lean-101.conf b/deploy/nginx/clients.lean-101.conf index 83c9e51..05848b7 100644 --- a/deploy/nginx/clients.lean-101.conf +++ b/deploy/nginx/clients.lean-101.conf @@ -27,7 +27,9 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always; + # frame-src/child-src allow same-origin blob: URLs so the in-app PDF print + # dialog (an iframe pointed at a blob:) is not blocked by the default-src fallback. + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always; location /_app/immutable/ { expires 1y; @@ -90,6 +92,14 @@ server { location / { add_header Cache-Control "no-store, no-cache, must-revalidate" always; add_header Pragma "no-cache" always; + # nginx drops inherited add_header directives once a location defines its own, + # so the security headers (incl. the blob:-aware CSP) are repeated here to + # guarantee the HTML document carries them. + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always; expires -1; proxy_pass http://lean101_clients_frontend; proxy_http_version 1.1; diff --git a/docker-compose.production.yml b/docker-compose.production.yml index b36fcfc..9ac0fcd 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -45,6 +45,9 @@ services: LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8} LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300} DOCS_ENABLED: ${DOCS_ENABLED:-false} + # Read-only Power BI data API (/api/v1). Blank disables it. + POWERBI_API_KEY: ${POWERBI_API_KEY:-} + POWERBI_TENANT_ID: ${POWERBI_TENANT_ID:-${CLIENT_TENANT_ID:-hunter-premium-produce}} depends_on: db: condition: service_healthy diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a959030..d47b45f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.23", + "version": "0.1.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.23", + "version": "0.1.26", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index 7c18d7a..90e4a43 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.23", + "version": "0.1.26", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d02694c..76cbb6e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -8,9 +8,12 @@ import type { ClientUserModulePermission, ClientUserUpdateInput, LoginResponse, + EditorMixCreateInput, EditorMixUpdateInput, EditorMixRow, EditorMixFormula, + EditorResolvedMixFormula, + EditorMixFormulaRowInput, EditorIngredientRow, EditorIngredientCreateInput, EditorIngredientUpdateInput, @@ -387,6 +390,11 @@ export const api = { const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes'; return cachedFetchJson(path, 'client', fetcher); }, + createEditorMix: (payload: EditorMixCreateInput) => + request('/api/editor/mixes', { + method: 'POST', + body: JSON.stringify(payload) + }, 'client'), updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) => request(`/api/editor/mixes/${mixId}`, { method: 'PATCH', @@ -394,6 +402,15 @@ export const api = { }, 'client'), editorMixFormula: (mixId: number) => request(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'), + // The resolved formula matching the Mix Calculator (product-first), used by + // the Mix Editor ingredient panel. + editorMixResolvedFormula: (mixId: number) => + request(`/api/editor/mixes/${mixId}/formula`, {}, 'client'), + replaceEditorMixFormula: (mixId: number, rows: EditorMixFormulaRowInput[]) => + request(`/api/editor/mixes/${mixId}/formula`, { + method: 'PUT', + body: JSON.stringify({ rows }) + }, 'client'), addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) => request(`/api/editor/mixes/${mixId}/ingredients`, { method: 'POST', diff --git a/frontend/src/lib/changelog.ts b/frontend/src/lib/changelog.ts index c1f9cc4..5fe1d98 100644 --- a/frontend/src/lib/changelog.ts +++ b/frontend/src/lib/changelog.ts @@ -73,5 +73,14 @@ export function changelogFor(version: string): ChangelogEntry | undefined { return changelog.find((entry) => entry.version === version); } -/** The entry for the version the app is currently running, if documented. */ -export const currentChangelog: ChangelogEntry | undefined = changelogFor(APP_VERSION); +/** The most recent changelog entry, regardless of the running version. */ +export const latestChangelog: ChangelogEntry | undefined = changelog[0]; + +/** + * The entry the "What's new" dialog shows. Prefer an exact match for the running + * version, but fall back to the latest documented entry so the manual button + * always opens something even when the current build's version (e.g. a hotfix + * suffix like `0.1.24b`) isn't itself listed in the changelog. + */ +export const currentChangelog: ChangelogEntry | undefined = + changelogFor(APP_VERSION) ?? latestChangelog; diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 42e3e9f..7237108 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -2,13 +2,13 @@ import { api } from '$lib/api'; import AuthGate from '$lib/components/AuthGate.svelte'; import WorkspaceBootCard from '$lib/components/app-shell/WorkspaceBootCard.svelte'; - import WorkspaceQuickAccess from '$lib/components/app-shell/WorkspaceQuickAccess.svelte'; - import WorkspaceSearchPalette from '$lib/components/app-shell/WorkspaceSearchPalette.svelte'; import WorkspaceSignedOutCard from '$lib/components/app-shell/WorkspaceSignedOutCard.svelte'; import WorkspaceTabletNav from '$lib/components/app-shell/WorkspaceTabletNav.svelte'; + import WorkspaceAppsFab, { type WorkspaceFabItem } from '$lib/components/WorkspaceAppsFab.svelte'; import { PALETTE_RESULT_LIMIT, buildSessionKey, filterSearchItems } from '$lib/components/app-shell/utils'; import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte'; import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte'; + import WorkspacePageHeader from '$lib/components/navigation/WorkspacePageHeader.svelte'; import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte'; import { currentChangelog } from '$lib/changelog'; import { hasSeenVersion, markVersionSeen } from '$lib/whats-new'; @@ -20,6 +20,7 @@ import { canCreateMixSession as sessionCanCreateMixSession, canCreateMixWorksheet as sessionCanCreateMixWorksheet, + canOpenClientAccess as sessionCanOpenClientAccess, canOpenDashboard as sessionCanOpenDashboard, canOpenEditor as sessionCanOpenEditor, canOpenMixCalculator as sessionCanOpenMixCalculator, @@ -36,9 +37,9 @@ isWorkspaceRouteAllowed } from '$lib/workspace-access'; import { + accessControlItem, baseSearchItems, buildClientNavEntries, - clientBreadcrumbs, dashboardItem, editorItem, ingredientsEditorItem, @@ -47,7 +48,7 @@ orderingItem, orderingManageChildren, orderingManageGroup, - pageTitle, + pageMeta, productCostingItem, reportingItem, throughputItem, @@ -63,9 +64,11 @@ let { children } = $props(); const isRootRoute = $derived(page.url.pathname === '/'); - let paletteOpen = $state(false); - let paletteQuery = $state(''); - let quickMenuOpen = $state(false); + let searchOpen = $state(false); + let searchQuery = $state(''); + let searchFocusRequest = $state(0); + let appsFabOpen = $state(false); + let sidebarOpen = $state(true); let userMenuOpen = $state(false); let navOpen = $state(false); let showBottomNav = $state(false); @@ -78,6 +81,8 @@ let seededSearchItems = $state([]); let seededSearchKey = $state(null); let bootDelayDone = $state(false); + let sidebarStateReady = $state(false); + const SIDEBAR_STORAGE_KEY = 'hsf:shell:sidebar-open'; const appVersion = `v${packageInfo.version}`; const currentYear = new Date().getFullYear(); const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession)); @@ -92,9 +97,10 @@ const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname)); const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed)); const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname); - const shellTitle = $derived(routeGuardPending ? 'Loading Workspace' : pageTitle(page.url.pathname)); - const shellBreadcrumbs = $derived( - routeGuardPending ? clientBreadcrumbs(workspaceHomeHref, $clientSession) : clientBreadcrumbs(page.url.pathname, $clientSession) + const shellPageMeta = $derived( + routeGuardPending + ? { title: 'Loading Workspace', category: 'Workspace', icon: dashboardItem.icon } + : pageMeta(page.url.pathname) ); const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null); const visibleWorkingDocumentItems = $derived( @@ -126,9 +132,11 @@ 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. + const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null); + // Grouped desktop rail: Dashboard, a collapsible "Operations" family (costing + // tools plus throughput), then the standalone ordering/insights modules. Built + // from the same access-filtered items, so a role only ever sees the families it + // may open. const navEntries = $derived( buildClientNavEntries({ dashboard: visibleDashboardItem, @@ -149,6 +157,37 @@ const visibleFooterLinks = $derived([ ...(!isOperationsUser ? footerLinks : []) ] as FooterLink[]); + const fabItems = $derived.by(() => { + const items = [ + visibleDashboardItem, + ...visibleWorkingDocumentItems, + visibleMixCalculatorItem, + visibleProductCostingItem, + visibleThroughputItem, + visibleOrderingEntry?.kind === 'item' + ? visibleOrderingEntry.item + : visibleOrderingEntry?.group + ? { + href: visibleOrderingEntry.group.href ?? '/ordering/manage', + label: visibleOrderingEntry.group.label, + icon: visibleOrderingEntry.group.icon + } + : null, + visibleReportingItem, + visibleEditorItem, + visibleIngredientsEditorItem, + visibleAccessControlItem + ].filter((item): item is { href: string; label: string; icon: WorkspaceFabItem['icon'] } => Boolean(item)); + + const seen = new Set(); + return items.flatMap((item) => { + if (seen.has(item.href)) { + return []; + } + seen.add(item.href); + return [{ href: item.href, label: item.label, icon: item.icon } satisfies WorkspaceFabItem]; + }); + }); const primaryBottomNavigation = $derived( [ ...(visibleDashboardItem ? [visibleDashboardItem] : []), @@ -176,11 +215,37 @@ ); const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]); const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone)); + const showDesktopSidebar = $derived(!showBottomNav); - function openPalette(query = '') { - paletteQuery = query; - paletteOpen = true; - quickMenuOpen = false; + function restoreSidebarState() { + if (typeof window === 'undefined') { + return true; + } + + try { + return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) !== 'false'; + } catch { + return true; + } + } + + function persistSidebarState() { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarOpen)); + } catch { + // Storage failures should not block shell interactions. + } + } + + function openSearch(query = '') { + searchQuery = query; + searchOpen = true; + searchFocusRequest += 1; + appsFabOpen = false; userMenuOpen = false; navOpen = false; } @@ -194,13 +259,13 @@ } async function runSearchItem(item: SearchItem) { - paletteOpen = false; - paletteQuery = ''; + searchOpen = false; + searchQuery = ''; await goto(item.href); } async function openSettings() { - quickMenuOpen = false; + appsFabOpen = false; userMenuOpen = false; navOpen = false; await goto('/settings'); @@ -220,17 +285,26 @@ } } - const paletteState = $derived(filterSearchItems(searchItems, paletteQuery, PALETTE_RESULT_LIMIT)); + const searchState = $derived(filterSearchItems(searchItems, searchQuery, PALETTE_RESULT_LIMIT)); $effect(() => { page.url.pathname; - quickMenuOpen = false; + appsFabOpen = false; userMenuOpen = false; - paletteOpen = false; - paletteQuery = ''; + searchOpen = false; + searchQuery = ''; navOpen = false; }); + $effect(() => { + if (!sidebarStateReady) { + return; + } + + sidebarOpen; + persistSidebarState(); + }); + $effect(() => { const hydrated = $sessionHydrated; const sessionKey = buildSessionKey($clientSession); @@ -271,14 +345,14 @@ }); }); - // Search palette items are seeded lazily — three list endpoints worth of - // data only when the user actually opens the palette, not on every login or + // Search items are seeded lazily — three list endpoints worth of + // data only when the user actually opens the search, not on every login or // navigation. Subsequent opens hit the api.ts cache. $effect(() => { const hydrated = $sessionHydrated; const session = $clientSession; const sessionKey = buildSessionKey(session); - const shouldSeed = paletteOpen; + const shouldSeed = searchOpen; if (!hydrated || !session || !sessionKey) { seededSearchItems = []; @@ -374,6 +448,9 @@ } onMount(() => { + sidebarOpen = restoreSidebarState(); + sidebarStateReady = true; + const bootTimer = window.setTimeout(() => { bootDelayDone = true; }, 1500); @@ -390,12 +467,12 @@ if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) { event.preventDefault(); - openPalette(); + openSearch(); } if (event.key === 'Escape') { - paletteOpen = false; - quickMenuOpen = false; + searchOpen = false; + appsFabOpen = false; userMenuOpen = false; navOpen = false; } @@ -422,7 +499,7 @@ - {shellTitle} | Hunter Premium Produce + {shellPageMeta.title} | Hunter Premium Produce {#if !$clientSession} @@ -436,9 +513,10 @@ {/if} {:else} -
- {#if !showBottomNav} +
+ {#if showDesktopSidebar} canUseWorkspaceSearch && openPalette()} + onRunSearchItem={runSearchItem} + onToggleSidebar={() => (sidebarOpen = !sidebarOpen)} onToggleUserMenu={() => { userMenuOpen = !userMenuOpen; - quickMenuOpen = false; + appsFabOpen = false; }} onOpenSettings={openSettings} onSignOut={signOut} @@ -472,6 +556,13 @@ />
+ {#if !routeGuardPending} + + {/if}
- openPalette('')} - /> +
openPalette('')} onOpenSettings={openSettings} onSignOut={signOut} /> @@ -531,16 +612,6 @@ {/if} -{#if $clientSession && paletteOpen} - (paletteOpen = false)} - onRunSearchItem={runSearchItem} - /> -{/if} - diff --git a/frontend/src/lib/components/app-shell/WorkspaceQuickAccess.svelte b/frontend/src/lib/components/app-shell/WorkspaceQuickAccess.svelte deleted file mode 100644 index 8c19604..0000000 --- a/frontend/src/lib/components/app-shell/WorkspaceQuickAccess.svelte +++ /dev/null @@ -1,161 +0,0 @@ - - -{#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canUseWorkspaceSearch} -
- {#if quickMenuOpen} - - {/if} - - -
-{/if} - - diff --git a/frontend/src/lib/components/app-shell/WorkspaceSearchPalette.svelte b/frontend/src/lib/components/app-shell/WorkspaceSearchPalette.svelte deleted file mode 100644 index 80877f6..0000000 --- a/frontend/src/lib/components/app-shell/WorkspaceSearchPalette.svelte +++ /dev/null @@ -1,201 +0,0 @@ - - - - - diff --git a/frontend/src/lib/components/app-shell/WorkspaceTabletNav.svelte b/frontend/src/lib/components/app-shell/WorkspaceTabletNav.svelte index 3a13f00..d104f3a 100644 --- a/frontend/src/lib/components/app-shell/WorkspaceTabletNav.svelte +++ b/frontend/src/lib/components/app-shell/WorkspaceTabletNav.svelte @@ -1,9 +1,8 @@ {#snippet leafLink(item: NavItem, showIcon: boolean)} {@const Icon = item.icon} - + {#if showIcon && Icon} {/if} - {item.label} - {#if item.badge}{item.badge}{/if} + {#if !collapsed} + {item.label} + {#if item.badge}{item.badge}{/if} + {/if} {/snippet} {#snippet actionRow(label: string, Icon: ComponentType, active: boolean, onSelect: () => void)} {@const RowIcon = Icon} - {/snippet} -