This commit is contained in:
2026-06-09 21:28:53 +12:00
parent daa6e60a69
commit 349e4a4b5b
61 changed files with 6404 additions and 1382 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
APP_NAME=Lean 101 Clients API
APP_NAME=Hunter App
CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=alex@lean-101.com
CLIENT_PASSWORD=JBBwVCDqmPA7
+1 -1
View File
@@ -1,4 +1,4 @@
APP_NAME=Lean 101 Clients API
APP_NAME=Hunter App
APP_ENV=alpha
CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=operator@example.com
+1 -1
View File
@@ -1,4 +1,4 @@
APP_NAME=Lean 101 Clients API
APP_NAME=Hunter App
APP_ENV=production
CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=operator@example.com
+1 -1
View File
@@ -1,4 +1,4 @@
APP_NAME=Lean 101 Clients API
APP_NAME=Hunter App
APP_ENV=production
CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=operator@example.com
+335
View File
@@ -0,0 +1,335 @@
# Tech Debt Audit & Remediation Plan
> Status: **Plan / not yet started.** Audit performed 2026-06-03 against `main`.
> Context: app has been through 11 versions. Dev runs on **SQLite (Windows)**;
> production is mid-migration to **PostgreSQL**. Six modules: Mix Calculator,
> Product Costing, Editor, Throughput, Reporting, Settings.
>
> Decisions already taken:
> - **Migrations:** adopt **Alembic** (replaces the startup `create_all` + ad-hoc `ALTER` scheme).
> - **Approach:** full audit first (this document), then execute in phases.
---
## Findings, ranked by severity
### P0 — correctness / data integrity
#### P0.1 — Money is stored as `Float` everywhere
Every cost / price / margin column is SQLAlchemy `Float`:
- `backend/app/models/product_costing.py``cleaned_product_cost_per_kg`, `grading_cost_per_kg`,
`bagging_cost_per_kg`, `cracking_cost_per_kg`, `bag_cost_per_unit`, `freight_cost_per_unit`,
`finished_product_delivered_cost`, `distributor_price`, `wholesale_price`, `cost_per_kg`,
`distributor_margin`, `wholesale_margin`, `cost`, …
- `backend/app/models/assumption.py``grading_cost`, `bagging_cost`, `cracking_cost`,
`bag_cost`, `cost_per_unit`.
- `backend/app/models/mix.py``quantity_kg`.
- `backend/app/models/mix_calculator.py``batch_size_kg`, `total_bags`, `total_kg`,
`product_unit_size_kg`, `required_kg`, `mix_percentage`.
- `backend/app/models/product.py``distributor_margin`, `wholesale_margin`, `quantity_kg`.
**Why it matters:** for a costing-and-pricing tool, binary floating point introduces rounding
drift in money. It is also a **SQLite ↔ Postgres divergence point** — SQLite stores loose floats,
Postgres `Numeric` is exact, so the same calculation can produce different stored/displayed values
across environments.
**Fix:** migrate money/quantity columns to `Numeric(12, 4)` (tune precision/scale per field) and use
`Decimal` in the calculation engine. Guard with the existing formula-parity tests.
#### P0.2 — Frontend silently shows mock data when the API fails
`frontend/src/lib/api.ts``fetchJson(path, fallback, ...)` returns the `fallback` (mock data) on
**any** fetch error:
- `api.ts:151` and `api.ts:158``return fallback;` on failure.
- Fallbacks are real mock datasets: `mockRawMaterials`, `mockCosts`, `mockProducts`, `mockMixes`,
`mockScenarios`, `mockMixCalculatorOptions`, `mockMixCalculatorSessions`, `mockClientAccess`
imported from `$lib/mock` (`api.ts:4-13`, used at `api.ts:296+`).
**Why it matters:** a backend hiccup makes the UI display **fabricated prices/costs** with no error
shown to the user. In a pricing application this is the most dangerous item in this audit — a user
could quote or decide off fake numbers.
**Fix:** remove the mock-on-error fallback path. Surface real API errors in the UI (error/empty
states). Keep `mock.ts` for tests only.
#### P0.3 — Schema management is `create_all` + ad-hoc `ALTER`, no versioning
`backend/app/db/migrations.py` runs on **every startup** via `bootstrap_schema()`
(`backend/app/main.py:105`, inside `ensure_database_ready()`):
- `ensure_metadata_tables()``metadata.create_all()` for any missing tables.
- `ensure_tenant_columns()` → adds `tenant_id` to a hardcoded `TENANT_TABLES` list.
- `ensure_legacy_columns()` → a hand-maintained `_LEGACY_COLUMN_PATCHES` tuple of
`ALTER TABLE ... ADD COLUMN` statements.
- `sync_tenant_ids()` → ~250 lines of near-identical `UPDATE` backfills.
- `sync_product_visibility()` → data backfill.
**Why it matters:** this can only **create tables and add columns**. It can never change a column
type, add an index / constraint / FK, drop a column, or do a NOT-NULL backfill in a controlled way.
A fresh Postgres gets the *current* model via `create_all`, while an upgraded SQLite has columns
bolted on by `ALTER` in whatever order/type they accreted — the two **drift apart silently**, and
SQLite's loose typing hides mismatches until production. Across 11 versions the only escape hatch
has been appending more manual patches (unbounded, fragile).
The one-shot SQLite→Postgres move (`deploy/migrate-to-postgres.sh`) uses
`SET session_replication_role` and manual sequence resets — fine for a single cutover, but not a
repeatable/testable migration path.
**Fix:** adopt **Alembic** (see Phase 1).
---
### P1 — maintainability
#### P1.1 — Copy-pasted helpers, no shared util
No shared formatting/number module. Duplicated implementations:
- `formatDate`**9** files: `lib/components/ClientAccessWorkspace.svelte`,
`lib/components/mix-calculator/MixCalculatorResultsPanel.svelte`,
`lib/components/MixCalculatorPrintDocument.svelte`, `routes/+page.svelte`,
`routes/admin/+page.svelte`, `routes/client-access/+page.svelte`,
`routes/mix-calculator/+page.svelte`, `routes/raw-materials/+page.svelte`,
`routes/throughput/+page.svelte`.
- `formatNumber`**5** files: `lib/components/mix-calculator/MixCalculatorEditor.svelte`,
`lib/components/mix-calculator/MixCalculatorResultsPanel.svelte`,
`lib/components/MixCalculatorPrintDocument.svelte`, `routes/mix-calculator/+page.svelte`,
`routes/throughput/+page.svelte`.
- `toNum`**2** files: `routes/throughput/+page.svelte`, `routes/throughput/add/+page.svelte`.
**Symptom already hit:** the `toNum` `value.trim is not a function` bug (Svelte coerces
`<input type="number">` bindings to `number`/`null`, not `string`). Fixed in both files
2026-06-03, but this class of bug will recur until there is a single source of truth.
**Fix:** add `frontend/src/lib/format.ts` (`formatDate`, `formatNumber`, `formatCurrency`, `toNum`)
and replace the duplicates.
#### P1.2 — Monolith route files
Largest route components (LOC):
| File | LOC |
| --- | --- |
| `routes/+page.svelte` (dashboard) | 2238 |
| `routes/product-costing/+page.svelte` | 1557 |
| `routes/throughput/+page.svelte` | 1232 |
| `routes/editor/+page.svelte` | 1163 |
| `routes/raw-materials/+page.svelte` | 1062 |
| `routes/client-access/+page.svelte` | 851 |
| `routes/reporting/+page.svelte` | 518 |
**Why it matters:** hard to test, hard to change safely, encourages more copy-paste.
**Fix:** decompose incrementally — extract components and `+page.ts` load logic. Dashboard and
product-costing first.
#### P1.3 — `migrations.py` conflates DDL + data backfill
Schema DDL (`ensure_*`) and data backfill (`sync_*`) live in one module, including ~250 lines of
near-identical `UPDATE` blocks in `sync_tenant_ids()`. Alembic will absorb most of this into
versioned schema steps + explicit data-migration steps.
---
### P2 — hygiene
- **P2.1** — `backend/tests/_repro_throughput_post.py` is a debug repro, not a real test. Remove.
- **P2.2** — ~20 `TODO`/`FIXME`/legacy markers across `backend/app` and `frontend/src`. Triage and burn down.
- **P2.3** — `backend/app/seed.py` is 1325 LOC. Split by module.
- **P2.4** — `CLAUDE.md` still says "PostgreSQL recommended / SQLite acceptable only for prototype",
stale vs the live Postgres migration. Refresh.
---
## P0.4 — Three overlapping authentication / user-type systems
The app has accreted **three separate auth systems**, with **two cookies**, **three login
endpoints**, **three role namespaces**, and two parallel permission models. They overlap awkwardly
and one of them is already dead in the UI. This is the single largest piece of structural debt in
the codebase.
### The three systems
**1. Internal / "lean" system**`users` / `roles` / `permissions` / `role_permissions`
- Code: `app/core/access.py`, `app/models/access.py`, `app/api/access.py` (`/api/access/*`),
`app/seed_access.py`.
- Per-user password hash (`User.password_hash`, PBKDF2). Role → permission keys
(`view_raw_materials`, `edit_products`, …). Fail-closed `require_permission(...)` dependencies.
- Token carries `sub=INTERNAL_USER_SUBJECT`; session `role="internal"`.
- Tenant is **hardcoded** to a constant: `INTERNAL_USER_TENANT_ID = "hunter-premium-produce"`
(`core/access.py:37`).
- Uses `CLIENT_AUTH_COOKIE`.
- **This is the actual primary login.** The root page (`routes/+page.svelte:57`) calls
`api.internalLogin()``/api/access/login`.
**2. Client-portal system**`client_accounts` / `client_users` / `client_feature_access` /
`client_user_module_permissions` / `client_access_audit_events`
- Code: `app/models/client_access.py`, `app/services/client_access_service.py`,
`app/api/auth.py` (`/api/auth/client/*`), `app/api/client_access.py`.
- **Multi-tenant** (`tenant_id` on every table), per-account feature flags, per-user module
access levels (`none`/`view`/`edit`/`manage`), `client_role` in {superadmin, admin, viewer, …}.
- Session `role="client"`. Uses `CLIENT_AUTH_COOKIE`.
- **Authentication is broken-by-design: a single shared password.** `client_login` checks
`payload.password != settings.client_password` (`api/auth.py:79`) — *one* password for *all*
client users; the per-user record only supplies identity, not a credential.
- **The login UI is dead.** `api.clientLogin` is referenced only by `api.ts` (definition) and
`api.test.ts` — no component calls it. The endpoints, tables, tenant plumbing, and the
`require_client_session` / `module_access_map` path are all still live and still wired into the
shared route dependencies.
**3. Admin system** — environment-variable single credential
- Code: `app/api/auth.py` (`/api/auth/admin/*`), `require_admin_session` in `app/api/deps.py`.
- No DB row. Authenticates against `settings.admin_email` / `settings.admin_password`.
Session `role="admin"`**blanket access** (`session.ts:105` `hasModuleAccess` returns `true`
for admin; `require_admin_session` gates the admin-only routes).
- Uses a **second cookie**, `ADMIN_AUTH_COOKIE`.
- Drives the separate `/admin` + `/admin/client-access` UI (`routes/admin/+page.svelte` calls
`api.adminLogin()`), which exists to manage client users / feature flags / Power BI preview —
i.e. the "management behind the scenes" layer we no longer want.
### How they tangle
- **Frontend** routes by URL: `/admin*``AdminShell`, everything else → `ClientShell`
(`routes/+layout.svelte:15,42-49`). Two `localStorage` session stores
(`data-entry-app-client-session`, `data-entry-app-admin-session`) in `session.ts`.
- **Shared route deps bend to accept two token shapes.** `require_client_session` and
`require_client_module_access` (`api/deps.py:97-184`) special-case `role=="internal"` to skip the
`ClientUser` DB lookup and read permissions from a role-derived map, while still supporting
`role=="client"`. `core/access.py:_PERMISSION_TO_MODULE_LEVEL` exists purely to translate the
internal permission keys into the legacy client module/level shape so the same routes accept both.
- **Two permission models run in parallel**: role→permission-keys (internal) vs
per-user module→access-level rows + per-account feature flags (client). `permissions_to_module_map`
bridges them.
- **`tenant_id` is smeared across ~25 tables and ~25 backend files** (heaviest:
`db/migrations.py` 70 refs, `seed.py` 52, `api/product_costing.py` 36, plus every service/model),
for multi-tenancy we no longer want.
### Target architecture (per product direction)
> One login for everyone. User type `lean` = full access. `client` = its own permissions.
> No multi-tenant. No separate behind-the-scenes management app, except `lean` may have a few
> extra settings (e.g. change logo).
- **One login endpoint + one login page** for all users.
- **One user store**: keep `users` / `roles` / `permissions` / `role_permissions`. Everyone is a
`User` with a role. Add a **`lean`** role = all permissions (full access, including the extra
settings like logo). Define a **`client`** role with its own permission set. Operations etc. stay
as additional roles.
- **One cookie**, one session shape, one frontend session store.
- **Remove multi-tenancy**: drop `tenant_id` from models/queries/migrations/seed; collapse to a
single implicit tenant.
- **Retire the env-var admin login** and the **dead client-portal login** + its tables/service,
folding any still-needed capability (e.g. managing users) into permission-gated routes inside the
single app. `lean`-only settings (logo, etc.) become permission-gated, not a separate shell.
### Decoupling / migration approach (proposed)
1. **Confirm the dead path is dead** (done: `clientLogin` has no UI caller) and snapshot any client
data worth keeping (`client_users`, module permissions) so it can be re-expressed as `users` +
`roles` if needed.
2. **Unify on the `users`/`roles`/`permissions` model.** Introduce `lean` and `client` roles in
`seed_access.py` with the right permission sets. Migrate any real client users into `users`.
3. **Single login**: make `/api/access/login` the only login; one cookie; one session store; one
login page. Remove `/api/auth/admin/*`, `/api/auth/client/*`, `ADMIN_AUTH_COOKIE`, the
admin/client localStorage split, and the `/admin*` shell routing (fold any surviving admin
screens into permission-gated routes in the main app).
4. **Collapse the dual permission model**: drop `_PERMISSION_TO_MODULE_LEVEL` bridging and the
`role=="internal"` / `role=="client"` special-casing in `deps.py`; every route depends on
`require_permission(...)` (or a thin module-level wrapper) only.
5. **Drop multi-tenancy**: Alembic migration removing `tenant_id` columns (or leaving them nullable
and unused first, then dropping), plus removing `tenant_id` filters from services/queries and the
`sync_tenant_ids` backfill. **Sequence this on top of Phase 1 (Alembic)** so the column drops are
versioned and run identically on SQLite and Postgres.
6. **Delete the client-portal subsystem** once nothing references it: `models/client_access.py`,
`client_access_service.py`, `api/auth.py`, `api/client_access.py`, related schemas, and the
`ClientShell`/`AdminShell` split.
### Risk notes
- This touches **authentication** — stage it carefully behind tests; do not delete the old endpoints
until the unified login is proven in dev against both SQLite and (a Postgres copy of) prod.
- The shared password (`P0.4`/system 2) and the env-admin credential should be considered a
**security cleanup**, not just structure: per-user hashed passwords for everyone is the target.
- Dropping `tenant_id` is irreversible data-wise — do it as a dedicated, reviewed Alembic step with
a backup, after the login unification has settled.
---
## Remediation plan (phased)
### Phase 0 — Safety net (no behavior change)
- Add a schema-parity smoke test: fresh-SQLite `create_all` vs `Base.metadata` so later phases
cannot silently drift.
- Remove `backend/tests/_repro_throughput_post.py`.
### Phase 1 — Adopt Alembic *(foundation; most moving parts)*
- Add `alembic` to `backend/pyproject.toml`; `alembic init`.
- Wire `env.py` to read `DATABASE_URL` (via `app.core.config.settings`) and `Base.metadata`.
- **Critical for this setup:** enable `render_as_batch=True` so `ALTER` works on **SQLite (Windows dev)**;
Postgres handles `ALTER` natively.
- Autogenerate a **`0001_baseline`** migration from current models.
- `alembic stamp 0001_baseline` on existing dev **and** prod DBs so they are recognized without rebuilding.
- Fold `_LEGACY_COLUMN_PATCHES`, `sync_tenant_ids`, and `sync_product_visibility` into versioned
migrations (schema steps + explicit data-migration steps).
- Replace the startup `bootstrap_schema(...)` call (`main.py:105`) with `alembic upgrade head`
(or an explicit deploy step).
- Update `deploy/migrate-to-postgres.sh` **Phase 5** to run `alembic upgrade head` instead of
calling `bootstrap_schema`.
### Phase 2 — Money correctness
- `Float → Numeric(12, 4)` (tune per field) across the money/quantity columns listed in P0.1.
- Use `Decimal` in `services/costing_engine.py` and `services/product_costing_service.py`.
- Dedicated Alembic migration; guard with `tests/test_costing_engine.py` formula-parity tests.
### Phase 3 — Frontend shared utils
- New `frontend/src/lib/format.ts`: `formatDate`, `formatNumber`, `formatCurrency`, `toNum`.
- Replace the 9 / 5 / 2 duplicate implementations. Eliminates the `toNum`-style bug class.
### Phase 4 — Remove mock-on-error fallback *(quick, high-value correctness fix)*
- Remove the `fallback` return path in `api.ts` `fetchJson`.
- Surface real API errors / empty states in the UI.
- Keep `mock.ts` for tests only.
### Phase 5 — Unify authentication & user types *(addresses P0.4; large, cross-cutting)*
Sits on top of Phase 1 (Alembic) because the column drops must be versioned. Order within the phase:
1. Snapshot/migrate any real client users into `users` + `roles`; add `lean` and `client` roles in
`seed_access.py`.
2. Single login: make `/api/access/login` the only login; one cookie; one session store; one login
page. Remove `/api/auth/admin/*`, `/api/auth/client/*`, `ADMIN_AUTH_COOKIE`, and the `/admin*`
shell split.
3. Collapse the dual permission model — every route on `require_permission(...)`; delete the
`internal`/`client` special-casing and `_PERMISSION_TO_MODULE_LEVEL` bridge in `deps.py`/`access.py`.
4. Drop multi-tenancy (`tenant_id`) via a dedicated Alembic migration + query cleanup; remove
`sync_tenant_ids`.
5. Delete the dead client-portal subsystem (`models/client_access.py`, `client_access_service.py`,
`api/auth.py`, `api/client_access.py`, `AdminShell`).
6. `lean`-only extras (logo change, etc.) become permission-gated settings in the single app.
### Phase 6 — Decompose monolith routes
- Incrementally extract components + `+page.ts` load logic. Start with dashboard (`+page.svelte`)
and product-costing.
### Phase 7 — Hygiene
- Burn down `TODO`/legacy markers.
- Split `seed.py` by module.
- Refresh `CLAUDE.md` DB guidance.
---
## Suggested sequencing note
Phase 1 (Alembic) is the foundation the SQLite-dev / Postgres-prod split most depends on. However,
**Phase 4 (mock-on-error)** is the scariest correctness bug and a ~20-minute fix — a reasonable
quick win to do first, before Phase 1.
## Progress log
- 2026-06-03 — Audit completed; plan written. `toNum` bug fixed in
`routes/throughput/+page.svelte` and `routes/throughput/add/+page.svelte` (precursor to Phase 3).
- 2026-06-03 — Auth/user-type investigation added (P0.4 + Phase 5). Found three overlapping auth
systems; the client-portal login (`/api/auth/client/login`, shared password) is already dead in
the UI (`clientLogin` has no component caller). Target: single login, `lean`/`client` roles, no
multi-tenant, no separate admin shell.
- 2026-06-04 — Phase 4 quick win started: removed production `api.ts` mock-on-error fallback so
failed reads throw normalized API errors instead of returning fabricated mock pricing/costing data.
Removed `backend/tests/_repro_throughput_post.py` debug repro file.
- 2026-06-04 — Phase 0 safety net started: added a fresh SQLite schema smoke test that checks
model metadata tables and columns are created as declared.
- 2026-06-04 — Phase 3 shared utils started: added `frontend/src/lib/format.ts`, covered it with
unit tests, and replaced the duplicated `toNum` helper plus the mix-calculator/throughput number
and date formatters touched in recent work.
+167
View File
@@ -7,6 +7,9 @@ breakdowns, scenarios, data-quality) and only used summaries from each.
"""
from __future__ import annotations
from datetime import date
import json
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
@@ -15,7 +18,9 @@ from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
from app.models.product_costing import ProductCostItem
from app.models.raw_material import RawMaterial
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.services.client_access_service import has_access_level
from app.services.costing_engine import (
calculate_mix_cost,
@@ -33,6 +38,166 @@ def _can(session: AuthSession, module_key: str) -> bool:
return has_access_level(permissions.get(module_key), "view")
def _month_start(today: date) -> date:
return today.replace(day=1)
def _warnings(item: ProductCostItem) -> list[str]:
if not item.warnings:
return []
try:
parsed = json.loads(item.warnings)
return parsed if isinstance(parsed, list) else [str(parsed)]
except json.JSONDecodeError:
return [item.warnings]
def _pricing_key(value: str | None) -> str:
return (value or "").strip().lower()
def _find_pricing_item(
entry: ProductionThroughput,
product: ThroughputProduct | None,
by_item_id: dict[str, ProductCostItem],
by_name: dict[str, ProductCostItem],
) -> ProductCostItem | None:
if product and product.item_id and product.item_id in by_item_id:
return by_item_id[product.item_id]
return by_name.get(_pricing_key(entry.product_name_snapshot)) or by_name.get(_pricing_key(product.name if product else None))
def _operations_summary(session: AuthSession, db: Session) -> dict | None:
if not (_can(session, "operations_throughput") or _can(session, "products") or _can(session, "dashboard")):
return None
today = date.today()
start = _month_start(today)
entries = db.scalars(
select(ProductionThroughput)
.where(
ProductionThroughput.tenant_id == session.tenant_id,
ProductionThroughput.production_date >= start,
ProductionThroughput.production_date <= today,
)
.options(selectinload(ProductionThroughput.product))
.order_by(ProductionThroughput.production_date.desc())
).all()
pricing_items = db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == session.tenant_id)).all()
by_item_id = {item.item_id: item for item in pricing_items if item.item_id}
by_name: dict[str, ProductCostItem] = {}
for item in pricing_items:
by_name.setdefault(_pricing_key(item.product_name), item)
by_name.setdefault(_pricing_key(item.mix_product_name), item)
product_totals: dict[str, dict] = {}
client_totals: dict[str, float] = {}
produced_not_priced: dict[str, dict] = {}
total_kg = 0.0
total_bags = 0.0
estimated_wholesale_value = 0.0
wholesale_rows = 0
for entry in entries:
kg = entry.calculated_kg or 0.0
bags = entry.quantity if entry.quantity_type == "bags" else 0.0
total_kg += kg
total_bags += bags
product = entry.product
name = entry.product_name_snapshot or product.name if product else entry.product_name_snapshot
bucket = product_totals.setdefault(
name,
{"product_name": name, "client_name": product.client_name if product else None, "kg": 0.0, "bags": 0.0, "entries": 0},
)
bucket["kg"] += kg
bucket["bags"] += bags
bucket["entries"] += 1
client = product.client_name if product and product.client_name else "Unassigned"
client_totals[client] = client_totals.get(client, 0.0) + kg
pricing = _find_pricing_item(entry, product, by_item_id, by_name)
pricing_warnings = _warnings(pricing) if pricing else ["Missing product pricing"]
wholesale_price = pricing.wholesale_price if pricing else None
unit_kg = pricing.unit_kg if pricing else None
if wholesale_price is not None:
units = kg / unit_kg if unit_kg and unit_kg > 0 else entry.quantity
estimated_wholesale_value += units * wholesale_price
wholesale_rows += 1
if pricing is None or pricing_warnings or wholesale_price is None:
missing = produced_not_priced.setdefault(
name,
{
"product_name": name,
"kg": 0.0,
"status": "Missing pricing" if pricing is None else "Needs review",
"warnings": pricing_warnings[:2],
},
)
missing["kg"] += kg
issue_counts = {
"missing_lookup": 0,
"missing_unit_kg": 0,
"missing_pallet_qty": 0,
"missing_price": 0,
"invalid_margin": 0,
}
for item in pricing_items:
warnings = " ".join(_warnings(item)).lower()
if "lookup" in warnings:
issue_counts["missing_lookup"] += 1
if "unit kg" in warnings:
issue_counts["missing_unit_kg"] += 1
if "pallet" in warnings:
issue_counts["missing_pallet_qty"] += 1
if item.distributor_price is None or item.wholesale_price is None:
issue_counts["missing_price"] += 1
if "margin" in warnings:
issue_counts["invalid_margin"] += 1
top_products = sorted(product_totals.values(), key=lambda row: row["kg"], reverse=True)[:5]
clients = [
{"client_name": client, "kg": round(kg, 2)}
for client, kg in sorted(client_totals.items(), key=lambda item: item[1], reverse=True)[:5]
]
produced_not_priced_rows = sorted(produced_not_priced.values(), key=lambda row: row["kg"], reverse=True)[:5]
return {
"period_label": "This month",
"total_kg": round(total_kg, 2),
"total_bags": round(total_bags, 2),
"entry_count": len(entries),
"estimated_wholesale_value": round(estimated_wholesale_value, 2),
"priced_entry_count": wholesale_rows,
"top_products": [
{
"product_name": row["product_name"],
"client_name": row["client_name"],
"kg": round(row["kg"], 2),
"bags": round(row["bags"], 2),
"entries": row["entries"],
}
for row in top_products
],
"client_totals": clients,
"pricing_issues": {
**issue_counts,
"total": sum(issue_counts.values()),
},
"produced_not_priced": [
{
"product_name": row["product_name"],
"kg": round(row["kg"], 2),
"status": row["status"],
"warnings": row["warnings"],
}
for row in produced_not_priced_rows
],
}
@router.get("/summary")
def dashboard_summary(
session: AuthSession = Depends(require_client_module_access("dashboard")),
@@ -44,6 +209,7 @@ def dashboard_summary(
raw_series: list[float] = []
mix_series: list[float] = []
product_series: list[float] = []
operations_summary = _operations_summary(session, db)
if _can(session, "raw_materials") or _can(session, "dashboard"):
materials = db.scalars(
@@ -147,4 +313,5 @@ def dashboard_summary(
"mix_cost_per_kg": mix_series,
"product_finished_delivered": product_series,
},
"operations": operations_summary,
}
+247
View File
@@ -0,0 +1,247 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.schemas.product_costing import (
ProductCostInputsRead,
ProductCostInputsUpdate,
ProductCostItemCreate,
ProductCostItemRead,
ProductCostItemUpdate,
ProductCostRecalculateAllRead,
)
from app.services.product_costing_service import (
BAG_INPUTS,
FREIGHT_INPUTS,
PROCESS_NAMES,
recalculate_all_product_cost_items,
recalculate_product_cost_item,
serialize_product_cost_item,
)
router = APIRouter(prefix="/api/product-costing", tags=["product-costing"])
def _load_item(db: Session, tenant_id: str, item_id: int) -> ProductCostItem | None:
return db.scalar(select(ProductCostItem).where(ProductCostItem.id == item_id, ProductCostItem.tenant_id == tenant_id))
def _ensure_inputs(db: Session, tenant_id: str) -> ProductCostBaseInput:
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
if base is None:
base = ProductCostBaseInput(tenant_id=tenant_id)
db.add(base)
db.flush()
for process_name in PROCESS_NAMES:
if db.scalar(select(ProductCostProcessInput.id).where(ProductCostProcessInput.tenant_id == tenant_id, ProductCostProcessInput.process_name == process_name)) is None:
db.add(ProductCostProcessInput(tenant_id=tenant_id, process_name=process_name, cost_per_kg=0.0))
for key, label in BAG_INPUTS.items():
if db.scalar(select(ProductCostBagInput.id).where(ProductCostBagInput.tenant_id == tenant_id, ProductCostBagInput.input_key == key)) is None:
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=key, label=label, cost=0.0))
for key, label in FREIGHT_INPUTS.items():
if db.scalar(select(ProductCostFreightInput.id).where(ProductCostFreightInput.tenant_id == tenant_id, ProductCostFreightInput.input_key == key)) is None:
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=key, label=label, cost=0.0))
db.flush()
return base
def _serialize_inputs(db: Session, tenant_id: str) -> dict:
base = _ensure_inputs(db, tenant_id)
return {
"base": {
"grading_per_tonne": base.grading_per_tonne,
"grading_per_kg": base.grading_per_kg,
"cracking_per_tonne": base.cracking_per_tonne,
"cracking_per_kg": base.cracking_per_kg,
},
"processes": [
{"key": row.process_name, "label": row.process_name, "cost": row.cost_per_kg}
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id).order_by(ProductCostProcessInput.process_name)).all()
],
"clients": [
{
"client_category": row.client_category,
"distributor_margin": row.distributor_margin,
"wholesale_margin": row.wholesale_margin,
}
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id).order_by(ProductCostClientInput.client_category)).all()
],
"bags": [
{"key": row.input_key, "label": row.label, "cost": row.cost}
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id).order_by(ProductCostBagInput.input_key)).all()
],
"freight": [
{"key": row.input_key, "label": row.label, "cost": row.cost}
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id).order_by(ProductCostFreightInput.input_key)).all()
],
}
@router.get("/items", response_model=list[ProductCostItemRead])
def list_product_cost_items(
q: str | None = Query(default=None),
client_category: str | None = Query(default=None),
limit: int = Query(default=250, ge=1, le=1000),
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
statement = select(ProductCostItem).where(ProductCostItem.tenant_id == session.tenant_id)
if client_category:
statement = statement.where(ProductCostItem.client_category == client_category)
if q:
term = f"%{q}%"
statement = statement.where(
or_(
ProductCostItem.client_category.ilike(term),
ProductCostItem.item_id.ilike(term),
ProductCostItem.product_name.ilike(term),
ProductCostItem.mix_product_name.ilike(term),
)
)
items = db.scalars(statement.order_by(ProductCostItem.client_category, ProductCostItem.product_name).limit(limit)).all()
return [serialize_product_cost_item(item) for item in items]
@router.post("/items", response_model=ProductCostItemRead, status_code=status.HTTP_201_CREATED)
def create_product_cost_item(
payload: ProductCostItemCreate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = ProductCostItem(tenant_id=session.tenant_id or "default", **payload.model_dump())
db.add(item)
db.flush()
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.get("/items/{item_id}", response_model=ProductCostItemRead)
def get_product_cost_item(
item_id: int,
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
return serialize_product_cost_item(item)
@router.patch("/items/{item_id}", response_model=ProductCostItemRead)
def update_product_cost_item(
item_id: int,
payload: ProductCostItemUpdate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value)
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.post("/items/{item_id}/recalculate", response_model=ProductCostItemRead)
def recalculate_one(
item_id: int,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.post("/recalculate-all", response_model=ProductCostRecalculateAllRead)
def recalculate_all(
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
count = recalculate_all_product_cost_items(db, session.tenant_id or "default")
db.commit()
return {"recalculated": count}
@router.get("/inputs", response_model=ProductCostInputsRead)
def get_product_cost_inputs(
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
return _serialize_inputs(db, session.tenant_id or "default")
@router.patch("/inputs", response_model=ProductCostInputsRead)
def update_product_cost_inputs(
payload: ProductCostInputsUpdate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or "default"
base = _ensure_inputs(db, tenant_id)
if payload.base is not None:
for field, value in payload.base.model_dump().items():
setattr(base, field, value)
if payload.processes is not None:
existing = {row.process_name: row for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()}
for row in payload.processes:
target = existing.get(row.key)
if target is None:
db.add(ProductCostProcessInput(tenant_id=tenant_id, process_name=row.key, cost_per_kg=row.cost))
else:
target.cost_per_kg = row.cost
if payload.clients is not None:
existing = {row.client_category: row for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()}
for row in payload.clients:
target = existing.get(row.client_category)
if target is None:
db.add(ProductCostClientInput(tenant_id=tenant_id, client_category=row.client_category, distributor_margin=row.distributor_margin, wholesale_margin=row.wholesale_margin))
else:
target.distributor_margin = row.distributor_margin
target.wholesale_margin = row.wholesale_margin
if payload.bags is not None:
existing = {row.input_key: row for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()}
for row in payload.bags:
target = existing.get(row.key)
if target is None:
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=row.key, label=row.label, cost=row.cost))
else:
target.label = row.label
target.cost = row.cost
if payload.freight is not None:
existing = {row.input_key: row for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()}
for row in payload.freight:
target = existing.get(row.key)
if target is None:
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=row.key, label=row.label, cost=row.cost))
else:
target.label = row.label
target.cost = row.cost
db.flush()
recalculate_all_product_cost_items(db, tenant_id)
db.commit()
return _serialize_inputs(db, tenant_id)
+1 -1
View File
@@ -62,7 +62,7 @@ class Settings:
@classmethod
def from_env(cls) -> "Settings":
settings = cls(
app_name=os.getenv("APP_NAME", "Data Entry App API"),
app_name=os.getenv("APP_NAME", "Hunter App"),
app_env=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")),
host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "8000")),
+6
View File
@@ -29,6 +29,12 @@ TENANT_TABLES = {
"mix_calculator_sessions": None,
"mix_calculator_session_lines": None,
"products": None,
"product_cost_items": None,
"product_cost_base_inputs": None,
"product_cost_process_inputs": None,
"product_cost_client_inputs": None,
"product_cost_bag_inputs": None,
"product_cost_freight_inputs": None,
"scenarios": None,
"costing_results": None,
"process_cost_rules": None,
+2
View File
@@ -26,6 +26,7 @@ from app.api.editor import router as editor_router
from app.api.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_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.raw_materials import router as raw_materials_router
from app.api.scenarios import router as scenarios_router
@@ -199,6 +200,7 @@ app.include_router(editor_router)
app.include_router(raw_materials_router)
app.include_router(mixes_router)
app.include_router(mix_calculator_router)
app.include_router(product_costing_router)
app.include_router(products_router)
app.include_router(scenarios_router)
app.include_router(throughput_router)
+14
View File
@@ -4,6 +4,14 @@ from app.models.client_access import ClientAccessAuditEvent, ClientAccount, Clie
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.models.scenario import CostingResult, Scenario
from app.models.throughput import ProductionThroughput, ThroughputProduct
@@ -24,6 +32,12 @@ __all__ = [
"Permission",
"ProcessCostRule",
"Product",
"ProductCostBagInput",
"ProductCostBaseInput",
"ProductCostClientInput",
"ProductCostFreightInput",
"ProductCostItem",
"ProductCostProcessInput",
"ProductIngredient",
"ProductionThroughput",
"ThroughputProduct",
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class ProductCostItem(Base):
__tablename__ = "product_cost_items"
__table_args__ = (UniqueConstraint("tenant_id", "item_id", name="uq_product_cost_item_tenant_item"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_category: Mapped[str] = mapped_column(String(255), index=True)
item_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
product_name: Mapped[str] = mapped_column(String(255), index=True)
mix_product_name: Mapped[str] = mapped_column(String(255), index=True)
unit_type: Mapped[str] = mapped_column(String(32), default="Standard")
own_bag: Mapped[str | None] = mapped_column(String(32), nullable=True)
unit_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
items_per_pallet: Mapped[int | None] = mapped_column(Integer, nullable=True)
bagging_process: Mapped[str | None] = mapped_column(String(128), nullable=True)
manual_distributor_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
manual_wholesale_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
cleaned_product_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
grading_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
bagging_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
cracking_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
bag_cost_per_unit: Mapped[float | None] = mapped_column(Float, nullable=True)
freight_cost_per_unit: Mapped[float | None] = mapped_column(Float, nullable=True)
finished_product_delivered_cost: Mapped[float | None] = mapped_column(Float, nullable=True)
distributor_price: Mapped[float | None] = mapped_column(Float, nullable=True)
wholesale_price: Mapped[float | None] = mapped_column(Float, nullable=True)
warnings: Mapped[str | None] = mapped_column(Text, 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 ProductCostBaseInput(Base):
__tablename__ = "product_cost_base_inputs"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", unique=True, index=True)
grading_per_tonne: Mapped[float] = mapped_column(Float, default=0.0)
grading_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
cracking_per_tonne: Mapped[float] = mapped_column(Float, default=0.0)
cracking_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostProcessInput(Base):
__tablename__ = "product_cost_process_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "process_name", name="uq_product_cost_process_tenant_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
process_name: Mapped[str] = mapped_column(String(128), index=True)
cost_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostClientInput(Base):
__tablename__ = "product_cost_client_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "client_category", name="uq_product_cost_client_tenant_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_category: Mapped[str] = mapped_column(String(255), index=True)
distributor_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
wholesale_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostBagInput(Base):
__tablename__ = "product_cost_bag_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "input_key", name="uq_product_cost_bag_tenant_key"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
input_key: Mapped[str] = mapped_column(String(64), index=True)
label: Mapped[str] = mapped_column(String(128))
cost: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostFreightInput(Base):
__tablename__ = "product_cost_freight_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "input_key", name="uq_product_cost_freight_tenant_key"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
input_key: Mapped[str] = mapped_column(String(64), index=True)
label: Mapped[str] = mapped_column(String(128))
cost: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+97
View File
@@ -0,0 +1,97 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class ProductCostItemBase(BaseModel):
client_category: str = Field(min_length=1, max_length=255)
item_id: str | None = Field(default=None, max_length=128)
product_name: str = Field(min_length=1, max_length=255)
mix_product_name: str = Field(min_length=1, max_length=255)
unit_type: str = "Standard"
own_bag: str | None = None
unit_kg: float | None = Field(default=None, gt=0)
items_per_pallet: int | None = Field(default=None, gt=0)
bagging_process: str | None = Field(default=None, max_length=128)
manual_distributor_margin: float | None = Field(default=None, ge=0, lt=1)
manual_wholesale_margin: float | None = Field(default=None, ge=0, lt=1)
class ProductCostItemCreate(ProductCostItemBase):
pass
class ProductCostItemUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
client_category: str | None = Field(default=None, min_length=1, max_length=255)
item_id: str | None = Field(default=None, max_length=128)
product_name: str | None = Field(default=None, min_length=1, max_length=255)
mix_product_name: str | None = Field(default=None, min_length=1, max_length=255)
unit_type: str | None = None
own_bag: str | None = None
unit_kg: float | None = Field(default=None, gt=0)
items_per_pallet: int | None = Field(default=None, gt=0)
bagging_process: str | None = Field(default=None, max_length=128)
manual_distributor_margin: float | None = Field(default=None, ge=0, lt=1)
manual_wholesale_margin: float | None = Field(default=None, ge=0, lt=1)
class ProductCostItemRead(ProductCostItemBase):
id: int
tenant_id: str
cleaned_product_cost_per_kg: float | None
grading_cost_per_kg: float | None
bagging_cost_per_kg: float | None
cracking_cost_per_kg: float | None
bag_cost_per_unit: float | None
freight_cost_per_unit: float | None
finished_product_delivered_cost: float | None
distributor_price: float | None
wholesale_price: float | None
warnings: list[str]
created_at: datetime
updated_at: datetime
class ProductCostBaseInputRead(BaseModel):
grading_per_tonne: float
grading_per_kg: float
cracking_per_tonne: float
cracking_per_kg: float
class ProductCostBaseInputUpdate(ProductCostBaseInputRead):
pass
class ProductCostNamedInputRead(BaseModel):
key: str
label: str
cost: float
class ProductCostClientInputRead(BaseModel):
client_category: str
distributor_margin: float | None
wholesale_margin: float | None
class ProductCostInputsRead(BaseModel):
base: ProductCostBaseInputRead
processes: list[ProductCostNamedInputRead]
clients: list[ProductCostClientInputRead]
bags: list[ProductCostNamedInputRead]
freight: list[ProductCostNamedInputRead]
class ProductCostInputsUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
base: ProductCostBaseInputUpdate | None = None
processes: list[ProductCostNamedInputRead] | None = None
clients: list[ProductCostClientInputRead] | None = None
bags: list[ProductCostNamedInputRead] | None = None
freight: list[ProductCostNamedInputRead] | None = None
class ProductCostRecalculateAllRead(BaseModel):
recalculated: int
+215 -1
View File
@@ -16,12 +16,26 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCos
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.seed_access import seed_access
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
from app.services.throughput_service import import_workbook as import_throughput_workbook
from app.services.throughput_service import resolve_workbook_path as resolve_throughput_workbook_path
from app.services.product_costing_service import (
BAG_INPUTS,
FREIGHT_INPUTS,
PROCESS_NAMES,
recalculate_all_product_cost_items,
)
TENANT_ID = "hunter-premium-produce"
@@ -691,7 +705,36 @@ def _upsert_product_ingredients(
for key, formula in product_ingredient_rows.items():
matched_products = products_by_formula_key.get(key, [])
if not matched_products:
continue
client_name, formula_name = key
mix_cache: dict[tuple[str, str], Mix] = {}
mix = _upsert_mix(
db,
client_name=client_name,
mix_name=formula_name,
ingredients=formula["ingredients"],
raw_material_map=raw_material_map,
mix_cache=mix_cache,
)
product = Product(
tenant_id=TENANT_ID,
client_name=client_name,
item_id=f"mix-calculator:{_slug(client_name, fallback='client')}:{_slug(formula_name, fallback='mix')}",
name=formula_name,
mix_id=mix.id,
sale_type="standard",
own_bag=True,
visible=True,
unit_of_measure="kg",
items_per_pallet=1,
bagging_process=None,
distributor_margin=None,
wholesale_margin=None,
notes="Seeded as a Mix Calculator source row from workbook formulas",
)
db.add(product)
db.flush()
products_by_formula_key[key] = [product]
matched_products = [product]
for product in matched_products:
existing_ingredients = {
@@ -1060,6 +1103,174 @@ def seed_throughput_products(db):
return
def _unit_type_from_product(product: Product) -> str:
sale_type = (product.sale_type or "").lower()
unit = (product.unit_of_measure or "").lower()
if sale_type == "bulka" or "bulka" in unit:
return "Bulka"
if "1.5kg" in unit or "1.5 kg" in unit:
return "1.5 kg"
if sale_type == "per_unit":
return "Per Unit"
return "Standard"
def _own_bag_label(product: Product) -> str | None:
if product.own_bag:
return "No Bag" if "no bag" in (product.unit_of_measure or "").lower() else "Yes"
return None
def seed_product_costing_module(db) -> dict[str, int]:
tenant_id = TENANT_ID
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
if base is None:
process_rules = db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == tenant_id)).all()
grading_per_kg = max((rule.grading_cost for rule in process_rules), default=0.0)
cracking_per_kg = max((rule.cracking_cost for rule in process_rules), default=0.0)
base = ProductCostBaseInput(
tenant_id=tenant_id,
grading_per_tonne=round(grading_per_kg * 1000, 4),
grading_per_kg=round(grading_per_kg, 4),
cracking_per_tonne=round(cracking_per_kg * 1000, 4),
cracking_per_kg=round(cracking_per_kg, 4),
)
db.add(base)
existing_processes = {
row.process_name: row
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()
}
process_rule_map = {
rule.process_name: rule
for rule in db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == tenant_id)).all()
}
for process_name in PROCESS_NAMES:
if process_name in existing_processes:
continue
normalized_key = _build_process_key(process_name, 0.0, 0.0, 0.0)
rule = process_rule_map.get(normalized_key or process_name) or process_rule_map.get(process_name)
db.add(
ProductCostProcessInput(
tenant_id=tenant_id,
process_name=process_name,
cost_per_kg=round(rule.bagging_cost, 4) if rule else 0.0,
)
)
for process_name, rule in process_rule_map.items():
if process_name not in existing_processes:
db.add(
ProductCostProcessInput(
tenant_id=tenant_id,
process_name=process_name,
cost_per_kg=round(rule.bagging_cost, 4),
)
)
bag_defaults = {
"20kg_bag": 0.0,
"bulka_bag": 0.0,
"own_bag_credit": 0.0,
"1_5kg_bagging": 0.0,
"peckish_bag": 0.0,
}
for rule in db.scalars(select(PackagingCostRule).where(PackagingCostRule.tenant_id == tenant_id)).all():
unit = (rule.unit_of_measure or "").lower()
if "1.5kg" in unit or "1.5 kg" in unit:
bag_defaults["1_5kg_bagging"] = max(bag_defaults["1_5kg_bagging"], rule.bag_cost)
elif "peckish" in unit:
bag_defaults["peckish_bag"] = max(bag_defaults["peckish_bag"], rule.bag_cost)
elif "bulka" in unit:
bag_defaults["bulka_bag"] = max(bag_defaults["bulka_bag"], rule.bag_cost)
elif "20kg" in unit:
bag_defaults["20kg_bag"] = max(bag_defaults["20kg_bag"], rule.bag_cost)
existing_bags = {
row.input_key
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()
}
for key, label in BAG_INPUTS.items():
if key not in existing_bags:
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=key, label=label, cost=round(bag_defaults.get(key, 0.0), 4)))
freight_defaults = {
"freight_per_pallet": 0.0,
"peckish_freight_per_pallet": 0.0,
"hay_straw_freight_per_pallet": 0.0,
}
for rule in db.scalars(select(FreightCostRule).where(FreightCostRule.tenant_id == tenant_id)).all():
unit = (rule.unit_of_measure or "").lower()
if "peckish" in unit:
freight_defaults["peckish_freight_per_pallet"] = max(freight_defaults["peckish_freight_per_pallet"], rule.cost_per_unit)
elif "hay" in unit or "straw" in unit:
freight_defaults["hay_straw_freight_per_pallet"] = max(freight_defaults["hay_straw_freight_per_pallet"], rule.cost_per_unit)
else:
freight_defaults["freight_per_pallet"] = max(freight_defaults["freight_per_pallet"], rule.cost_per_unit)
existing_freight = {
row.input_key
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()
}
for key, label in FREIGHT_INPUTS.items():
if key not in existing_freight:
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=key, label=label, cost=round(freight_defaults.get(key, 0.0), 4)))
existing_clients = {
row.client_category
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()
}
products = db.scalars(select(Product).where(Product.tenant_id == tenant_id).options(selectinload(Product.mix))).all()
margins: dict[str, list[tuple[float | None, float | None]]] = {}
for product in products:
margins.setdefault(product.client_name, []).append((product.distributor_margin, product.wholesale_margin))
for client_name, rows in margins.items():
if client_name in existing_clients:
continue
distributor_values = [value for value, _ in rows if value is not None]
wholesale_values = [value for _, value in rows if value is not None]
db.add(
ProductCostClientInput(
tenant_id=tenant_id,
client_category=client_name,
distributor_margin=round(sum(distributor_values) / len(distributor_values), 6) if distributor_values else None,
wholesale_margin=round(sum(wholesale_values) / len(wholesale_values), 6) if wholesale_values else None,
)
)
existing_items = {
item.item_id: item
for item in db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == tenant_id)).all()
if item.item_id
}
created = 0
for product in products:
if not product.item_id:
continue
item = existing_items.get(product.item_id)
if item is not None:
continue
item = ProductCostItem(
tenant_id=tenant_id,
client_category=product.client_name,
item_id=product.item_id,
product_name=product.name,
mix_product_name=product.mix.name if product.mix else product.name,
unit_type=_unit_type_from_product(product),
own_bag=_own_bag_label(product),
unit_kg=_infer_throughput_bag_size(product) or 1.0,
items_per_pallet=product.items_per_pallet,
bagging_process=product.bagging_process,
manual_distributor_margin=product.distributor_margin,
manual_wholesale_margin=product.wholesale_margin,
)
db.add(item)
created += 1
db.flush()
recalculated = recalculate_all_product_cost_items(db, tenant_id)
return {"created": created, "recalculated": recalculated}
def seed_startup_basics():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
@@ -1069,6 +1280,9 @@ def seed_startup_basics():
report = seed_product_ingredients_from_workbook(db)
if report["backfilled"]:
logger.info("Product ingredients backfilled from workbook: %s", report)
product_costing_report = seed_product_costing_module(db)
if any(product_costing_report.values()):
logger.info("Product costing module seeded: %s", product_costing_report)
db.commit()
+2 -2
View File
@@ -239,7 +239,7 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
current_y,
detail_width,
detail_height,
"Product",
"Mix",
session_record.product_name,
value_font_size=12,
)
@@ -251,7 +251,7 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
current_y,
detail_width,
detail_height,
"Mix source",
"Formula source",
session_record.mix_name,
value_font_size=11,
)
+45 -5
View File
@@ -76,6 +76,21 @@ def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_mea
)
def _mix_calculator_label(product: Product) -> str:
return product.mix.name if product.mix else product.name
def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int]:
unit_label = (product.unit_of_measure or "").lower()
unit_size = extract_unit_quantity_kg(product.unit_of_measure)
return (
0 if abs(unit_size - 20) < 1e-9 and "bag" in unit_label and "bulka" not in unit_label else 1,
0 if "bulka" not in unit_label else 1,
unit_size if unit_size > 0 else 999999,
product.id,
)
def calculate_mix_calculator_preview(
db: Session,
*,
@@ -117,12 +132,15 @@ def calculate_mix_calculator_preview(
}
)
mix_label = _mix_calculator_label(product)
return {
"client_name": product.client_name,
"product_id": product.id,
"product_name": product.name,
# The source workbook labels this as Product, but for the calculator
# it is the mix/formula being produced.
"product_name": mix_label,
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else product.name,
"mix_name": mix_label,
"mix_date": values["mix_date"],
"batch_size_kg": round(batch_size_kg, 4),
"total_bags": total_bags,
@@ -156,21 +174,43 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
).all()
mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows}
product_ids_with_formulas = select(ProductIngredient.product_id).where(ProductIngredient.tenant_id == tenant_id)
products = db.scalars(
select(Product)
.where(Product.tenant_id == tenant_id, Product.visible.is_(True))
.where(
Product.tenant_id == tenant_id,
Product.visible.is_(True),
Product.id.in_(product_ids_with_formulas),
)
.options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name)
).all()
representative_products: dict[tuple[str, str], Product] = {}
for product in products:
mix_label = _mix_calculator_label(product)
key = (product.client_name, mix_label)
current = representative_products.get(key)
if current is None:
representative_products[key] = product
continue
if _mix_calculator_option_rank(product) < _mix_calculator_option_rank(current):
representative_products[key] = product
products = sorted(
representative_products.values(),
key=lambda product: (product.client_name, _mix_calculator_label(product), product.id),
)
clients = sorted({product.client_name for product in products})
product_rows = [
{
"product_id": product.id,
"client_name": product.client_name,
"product_name": product.name,
"product_name": _mix_calculator_label(product),
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else "",
"mix_name": _mix_calculator_label(product),
"unit_of_measure": product.unit_of_measure,
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
"mix_total_kg": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)),
@@ -0,0 +1,338 @@
from __future__ import annotations
from dataclasses import dataclass
import json
import math
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.product import Product
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.services.costing_engine import calculate_product_cost
UNIT_TYPES = ("Standard", "Bulka", "1.5 kg", "Per Unit")
OWN_BAG_VALUES = ("Yes", "No Bag")
ZERO_GRADING_CLIENTS = {"PHF Horse Mixes", "Peckish", "Hay & Straw"}
PROCESS_NAMES = ("Bagging + Grading", "Standard Bagging", "PHF Horse Mixes", "Peckish", "Hay & Straw")
BAG_INPUTS = {
"20kg_bag": "20kg bag",
"bulka_bag": "Bulka bag",
"own_bag_credit": "Own bag credit",
"1_5kg_bagging": "1.5kg bagging",
"peckish_bag": "Peckish bag",
}
FREIGHT_INPUTS = {
"freight_per_pallet": "Freight per pallet",
"peckish_freight_per_pallet": "Peckish freight per pallet",
"hay_straw_freight_per_pallet": "Hay & Straw freight per pallet",
}
@dataclass(frozen=True)
class ProductCostInputItem:
client_category: str
product_name: str
mix_product_name: str
unit_type: str
own_bag: str | None
unit_kg: float | None
items_per_pallet: int | None
bagging_process: str | None
manual_distributor_margin: float | None
manual_wholesale_margin: float | None
@dataclass(frozen=True)
class ProductCostAssumptions:
grading_per_kg: float
cracking_per_kg: float
process_costs: dict[str, float]
client_margins: dict[str, dict[str, float | None]]
bag_costs: dict[str, float]
freight_costs: dict[str, float]
@dataclass(frozen=True)
class ProductCostCalculation:
cleaned_product_cost_per_kg: float | None
grading_cost_per_kg: float | None
bagging_cost_per_kg: float | None
cracking_cost_per_kg: float | None
bag_cost_per_unit: float | None
freight_cost_per_unit: float | None
finished_product_delivered_cost: float | None
distributor_price: float | None
wholesale_price: float | None
warnings: list[str]
def _round4(value: float | None) -> float | None:
return None if value is None else round(value, 4)
def _ceil_to(value: float, digits: int) -> float:
factor = 10**digits
return math.ceil((value * factor) - 1e-9) / factor
def _valid_margin(value: float | None, label: str, warnings: list[str]) -> float | None:
if value is None:
return None
if value < 0 or value >= 1:
warnings.append(f"Invalid {label} margin")
return None
return value
def calculate_product_cost_item(
item: ProductCostInputItem,
assumptions: ProductCostAssumptions,
cleaned_product_cost_per_kg: float | None,
) -> ProductCostCalculation:
warnings: list[str] = []
unit_type = item.unit_type or "Standard"
unit_kg = item.unit_kg
items_per_pallet = item.items_per_pallet
if unit_type not in UNIT_TYPES:
warnings.append("Invalid unit type")
if cleaned_product_cost_per_kg is None:
warnings.append("Missing mix/product cost lookup")
if unit_kg is None or unit_kg <= 0:
warnings.append("Missing unit kg")
if items_per_pallet is None or items_per_pallet <= 0:
warnings.append("Missing pallet quantity")
grading_cost_per_kg = 0.0
if item.client_category not in ZERO_GRADING_CLIENTS and item.bagging_process:
grading_cost_per_kg = assumptions.grading_per_kg
bagging_cost_per_kg = assumptions.process_costs.get(item.bagging_process or "", 0.0)
if item.bagging_process and item.bagging_process not in assumptions.process_costs:
warnings.append("Missing bagging process cost")
cracking_cost_per_kg = assumptions.cracking_per_kg if "cracked" in item.product_name.lower() else 0.0
bag_cost_per_unit = 0.0
if item.client_category == "Peckish":
bag_cost_per_unit = assumptions.bag_costs.get("peckish_bag", 0.0)
elif unit_type == "1.5 kg":
bag_cost_per_unit = assumptions.bag_costs.get("1_5kg_bagging", 0.0)
elif item.own_bag == "No Bag":
bag_cost_per_unit = 0.0
elif unit_type == "Standard":
bag_cost_per_unit = assumptions.bag_costs.get("20kg_bag", 0.0)
elif unit_type == "Bulka":
bag_cost_per_unit = assumptions.bag_costs.get("bulka_bag", 0.0) / unit_kg if unit_kg and unit_kg > 0 else None
if bag_cost_per_unit is not None and item.own_bag == "Yes":
bag_cost_per_unit -= assumptions.bag_costs.get("own_bag_credit", 0.0)
freight_cost_per_unit: float | None
if item.client_category == "Peckish":
freight_cost_per_unit = assumptions.freight_costs.get("peckish_freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
elif item.client_category == "Hay & Straw":
freight_cost_per_unit = assumptions.freight_costs.get("hay_straw_freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
elif unit_type in {"Standard", "Per Unit"}:
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
elif unit_type == "Bulka":
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / unit_kg if unit_kg and unit_kg > 0 else None
else:
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / 1000 * unit_kg if unit_kg and unit_kg > 0 else None
finished_cost = None
components = [cleaned_product_cost_per_kg, grading_cost_per_kg, bagging_cost_per_kg, cracking_cost_per_kg, bag_cost_per_unit, freight_cost_per_unit]
if all(value is not None for value in components) and unit_kg and unit_kg > 0:
per_kg_cost = cleaned_product_cost_per_kg + grading_cost_per_kg + bagging_cost_per_kg + cracking_cost_per_kg # type: ignore[operator]
if unit_type == "Standard":
finished_cost = per_kg_cost * unit_kg + bag_cost_per_unit + freight_cost_per_unit # type: ignore[operator]
elif unit_type in {"Bulka", "Per Unit"}:
finished_cost = per_kg_cost + bag_cost_per_unit + freight_cost_per_unit # type: ignore[operator]
else:
finished_cost = (per_kg_cost * unit_kg + bag_cost_per_unit + freight_cost_per_unit) * 8 # type: ignore[operator]
client_margin = assumptions.client_margins.get(item.client_category, {})
distributor_margin = _valid_margin(
item.manual_distributor_margin if item.manual_distributor_margin is not None else client_margin.get("distributor_margin"),
"distributor",
warnings,
)
wholesale_margin = _valid_margin(
item.manual_wholesale_margin if item.manual_wholesale_margin is not None else client_margin.get("wholesale_margin"),
"wholesale",
warnings,
)
distributor_price = finished_cost / (1 - distributor_margin) if finished_cost is not None and distributor_margin is not None else None
wholesale_price = finished_cost / (1 - wholesale_margin) if finished_cost is not None and wholesale_margin is not None else None
if wholesale_price is not None:
wholesale_price = _ceil_to(wholesale_price, 2 if item.client_category == "Straight Grain" and unit_type == "Bulka" else 1)
return ProductCostCalculation(
cleaned_product_cost_per_kg=_round4(cleaned_product_cost_per_kg),
grading_cost_per_kg=_round4(grading_cost_per_kg),
bagging_cost_per_kg=_round4(bagging_cost_per_kg),
cracking_cost_per_kg=_round4(cracking_cost_per_kg),
bag_cost_per_unit=_round4(bag_cost_per_unit),
freight_cost_per_unit=_round4(freight_cost_per_unit),
finished_product_delivered_cost=_round4(finished_cost),
distributor_price=_round4(distributor_price),
wholesale_price=_round4(wholesale_price),
warnings=warnings,
)
def _item_input(item: ProductCostItem) -> ProductCostInputItem:
return ProductCostInputItem(
client_category=item.client_category,
product_name=item.product_name,
mix_product_name=item.mix_product_name,
unit_type=item.unit_type,
own_bag=item.own_bag,
unit_kg=item.unit_kg,
items_per_pallet=item.items_per_pallet,
bagging_process=item.bagging_process,
manual_distributor_margin=item.manual_distributor_margin,
manual_wholesale_margin=item.manual_wholesale_margin,
)
def get_product_costing_assumptions(db: Session, tenant_id: str) -> ProductCostAssumptions:
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
if base is None:
base = ProductCostBaseInput(tenant_id=tenant_id)
db.add(base)
db.flush()
process_costs = {
row.process_name: row.cost_per_kg
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()
}
client_margins = {
row.client_category: {
"distributor_margin": row.distributor_margin,
"wholesale_margin": row.wholesale_margin,
}
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()
}
bag_costs = {
row.input_key: row.cost
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()
}
freight_costs = {
row.input_key: row.cost
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()
}
return ProductCostAssumptions(
grading_per_kg=base.grading_per_kg or ((base.grading_per_tonne or 0.0) / 1000),
cracking_per_kg=base.cracking_per_kg or ((base.cracking_per_tonne or 0.0) / 1000),
process_costs=process_costs,
client_margins=client_margins,
bag_costs=bag_costs,
freight_costs=freight_costs,
)
def lookup_cleaned_product_cost_per_kg(db: Session, item: ProductCostItem) -> float | None:
product = db.scalar(
select(Product)
.where(
Product.tenant_id == item.tenant_id,
Product.client_name == item.client_category,
Product.name == item.mix_product_name,
)
.limit(1)
)
if product is None:
product = db.scalar(
select(Product)
.where(
Product.tenant_id == item.tenant_id,
Product.client_name == item.client_category,
Product.name == item.product_name,
)
.limit(1)
)
if product is None:
return None
try:
result = calculate_product_cost(db, product.id)
except ValueError:
return None
mix = (result.get("inputs") or {}).get("mix") or {}
return mix.get("mix_cost_per_kg")
def apply_calculation(item: ProductCostItem, calculation: ProductCostCalculation) -> ProductCostItem:
item.cleaned_product_cost_per_kg = calculation.cleaned_product_cost_per_kg
item.grading_cost_per_kg = calculation.grading_cost_per_kg
item.bagging_cost_per_kg = calculation.bagging_cost_per_kg
item.cracking_cost_per_kg = calculation.cracking_cost_per_kg
item.bag_cost_per_unit = calculation.bag_cost_per_unit
item.freight_cost_per_unit = calculation.freight_cost_per_unit
item.finished_product_delivered_cost = calculation.finished_product_delivered_cost
item.distributor_price = calculation.distributor_price
item.wholesale_price = calculation.wholesale_price
item.warnings = json.dumps(calculation.warnings)
return item
def recalculate_product_cost_item(db: Session, item: ProductCostItem) -> ProductCostItem:
assumptions = get_product_costing_assumptions(db, item.tenant_id)
cleaned_cost = lookup_cleaned_product_cost_per_kg(db, item)
calculation = calculate_product_cost_item(_item_input(item), assumptions, cleaned_cost)
return apply_calculation(item, calculation)
def recalculate_all_product_cost_items(db: Session, tenant_id: str) -> int:
items = db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == tenant_id)).all()
for item in items:
recalculate_product_cost_item(db, item)
return len(items)
def serialize_product_cost_item(item: ProductCostItem) -> dict:
warnings = []
if item.warnings:
try:
warnings = json.loads(item.warnings)
except json.JSONDecodeError:
warnings = [item.warnings]
return {
"id": item.id,
"tenant_id": item.tenant_id,
"client_category": item.client_category,
"item_id": item.item_id,
"product_name": item.product_name,
"mix_product_name": item.mix_product_name,
"unit_type": item.unit_type,
"own_bag": item.own_bag,
"unit_kg": item.unit_kg,
"items_per_pallet": item.items_per_pallet,
"bagging_process": item.bagging_process,
"manual_distributor_margin": item.manual_distributor_margin,
"manual_wholesale_margin": item.manual_wholesale_margin,
"cleaned_product_cost_per_kg": item.cleaned_product_cost_per_kg,
"grading_cost_per_kg": item.grading_cost_per_kg,
"bagging_cost_per_kg": item.bagging_cost_per_kg,
"cracking_cost_per_kg": item.cracking_cost_per_kg,
"bag_cost_per_unit": item.bag_cost_per_unit,
"freight_cost_per_unit": item.freight_cost_per_unit,
"finished_product_delivered_cost": item.finished_product_delivered_cost,
"distributor_price": item.distributor_price,
"wholesale_price": item.wholesale_price,
"warnings": warnings,
"created_at": item.created_at,
"updated_at": item.updated_at,
}
@@ -1,10 +1,11 @@
Metadata-Version: 2.4
Name: data-entry-app-backend
Version: 0.1.5
Version: 0.1.12
Summary: Costing platform MVP backend
Requires-Python: >=3.11
Requires-Dist: fastapi<1.0,>=0.115
Requires-Dist: openpyxl<4.0,>=3.1
Requires-Dist: rich<15.0,>=13.9
Requires-Dist: uvicorn[standard]<1.0,>=0.30
Requires-Dist: sqlalchemy<3.0,>=2.0
Requires-Dist: pydantic<3.0,>=2.8
@@ -9,16 +9,23 @@ pyproject.toml
./app/api/client_access.py
./app/api/dashboard.py
./app/api/deps.py
./app/api/editor.py
./app/api/mix_calculator.py
./app/api/mixes.py
./app/api/powerbi.py
./app/api/product_costing.py
./app/api/products.py
./app/api/raw_materials.py
./app/api/scenarios.py
./app/api/throughput.py
./app/core/__init__.py
./app/core/access.py
./app/core/config.py
./app/core/http.py
./app/core/logging.py
./app/core/rate_limit.py
./app/core/security.py
./app/core/security_logging.py
./app/db/__init__.py
./app/db/migrations.py
./app/db/session.py
@@ -29,49 +36,89 @@ pyproject.toml
./app/models/mix.py
./app/models/mix_calculator.py
./app/models/product.py
./app/models/product_costing.py
./app/models/raw_material.py
./app/models/scenario.py
./app/models/throughput.py
./app/schemas/__init__.py
./app/schemas/client_access.py
./app/schemas/editor.py
./app/schemas/mix.py
./app/schemas/mix_calculator.py
./app/schemas/product.py
./app/schemas/product_costing.py
./app/schemas/raw_material.py
./app/schemas/scenario.py
./app/schemas/throughput.py
./app/services/__init__.py
./app/services/client_access_service.py
./app/services/costing_engine.py
./app/services/mix_calculator_filenames.py
./app/services/mix_calculator_pdf.py
./app/services/mix_calculator_service.py
./app/services/product_costing_service.py
./app/services/scenario_engine.py
./app/services/throughput_service.py
app/__init__.py
app/main.py
app/seed.py
app/seed_access.py
app/api/__init__.py
app/api/access.py
app/api/auth.py
app/api/client_access.py
app/api/dashboard.py
app/api/deps.py
app/api/editor.py
app/api/mix_calculator.py
app/api/mixes.py
app/api/powerbi.py
app/api/product_costing.py
app/api/products.py
app/api/raw_materials.py
app/api/scenarios.py
app/api/throughput.py
app/core/__init__.py
app/core/access.py
app/core/config.py
app/core/http.py
app/core/logging.py
app/core/rate_limit.py
app/core/security.py
app/core/security_logging.py
app/db/__init__.py
app/db/migrations.py
app/db/session.py
app/models/__init__.py
app/models/access.py
app/models/assumption.py
app/models/client_access.py
app/models/mix.py
app/models/mix_calculator.py
app/models/product.py
app/models/product_costing.py
app/models/raw_material.py
app/models/scenario.py
app/models/throughput.py
app/schemas/__init__.py
app/schemas/client_access.py
app/schemas/editor.py
app/schemas/mix.py
app/schemas/mix_calculator.py
app/schemas/product.py
app/schemas/product_costing.py
app/schemas/raw_material.py
app/schemas/scenario.py
app/schemas/throughput.py
app/services/__init__.py
app/services/client_access_service.py
app/services/costing_engine.py
app/services/mix_calculator_filenames.py
app/services/mix_calculator_pdf.py
app/services/mix_calculator_service.py
app/services/product_costing_service.py
app/services/scenario_engine.py
app/services/throughput_service.py
data_entry_app_backend.egg-info/PKG-INFO
data_entry_app_backend.egg-info/SOURCES.txt
data_entry_app_backend.egg-info/dependency_links.txt
@@ -79,3 +126,6 @@ data_entry_app_backend.egg-info/requires.txt
data_entry_app_backend.egg-info/top_level.txt
tests/test_access.py
tests/test_costing_engine.py
tests/test_product_costing.py
tests/test_schema.py
tests/test_throughput.py
@@ -1,5 +1,6 @@
fastapi<1.0,>=0.115
openpyxl<4.0,>=3.1
rich<15.0,>=13.9
uvicorn[standard]<1.0,>=0.30
sqlalchemy<3.0,>=2.0
pydantic<3.0,>=2.8
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "data-entry-app-backend"
version = "0.1.9"
version = "0.1.12"
description = "Costing platform MVP backend"
requires-python = ">=3.11"
dependencies = [
-62
View File
@@ -1,62 +0,0 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db.session import Base, get_db
from app.core.access import INTERNAL_USER_SUBJECT, INTERNAL_USER_TENANT_ID
from app.core.security import issue_token
from app.models.access import User
from app.models.throughput import ThroughputProduct
from app.api.throughput import router as throughput_router
from app.seed import seed_access
def run():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(bind=engine)
db = sessionmaker(bind=engine, expire_on_commit=False)()
seed_access(db)
product = ThroughputProduct(
tenant_id=INTERNAL_USER_TENANT_ID,
name="Test Product 20kg",
default_bag_size=20,
active=True,
)
db.add(product)
db.commit()
app = FastAPI()
app.dependency_overrides[get_db] = lambda: iter([db])
app.include_router(throughput_router)
client = TestClient(app)
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": ops.id, "email": ops.email})
payload = {
"production_date": "2026-06-01",
"product_id": product.id,
"product_name_snapshot": "Test Product 20kg",
"bag_size": 20,
"quantity": 10,
"quantity_type": "bags",
"for_order": True,
"for_stock": False,
"job_number": "JOB123",
"stock_quantity": None,
"staff_name": "Jake",
"notes": None,
}
resp = client.post(
"/api/throughput/entries",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
print("STATUS:", resp.status_code)
print("BODY:", resp.text[:1500])
if __name__ == "__main__":
run()
+10 -4
View File
@@ -246,7 +246,7 @@ def test_mix_calculator_options_hide_invisible_products_and_clients():
options = build_mix_calculator_options(db, tenant_id="hunter-premium-produce")
assert options["clients"] == ["Peckish"]
assert [product["product_name"] for product in options["products"]] == ["Visible Product"]
assert [product["product_name"] for product in options["products"]] == ["Visible Mix"]
assert options["products"][0]["mix_total_kg"] == 20
@@ -482,8 +482,12 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
assert options_response.status_code == 200
options_payload = options_response.json()
assert len(options_payload["products"]) >= 100
seeded_product = next(product for product in options_payload["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg")
assert len(options_payload["products"]) == 84
seeded_product = next(
product
for product in options_payload["products"]
if product["client_name"] == "Specialty" and product["product_name"] == "Pigeon Mix"
)
assert seeded_product["unit_size_kg"] == 20
create_response = client.post(
@@ -540,7 +544,9 @@ def test_mix_calculator_pdf_endpoint_returns_pdf():
options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
seeded_product = next(
product for product in options_response.json()["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg"
product
for product in options_response.json()["products"]
if product["client_name"] == "Specialty" and product["product_name"] == "Pigeon Mix"
)
create_response = client.post(
+169
View File
@@ -0,0 +1,169 @@
from app.services.product_costing_service import (
ProductCostAssumptions,
ProductCostInputItem,
calculate_product_cost_item,
)
def assumptions() -> ProductCostAssumptions:
return ProductCostAssumptions(
grading_per_kg=0.05,
cracking_per_kg=0.03,
process_costs={
"Bagging + Grading": 0.04,
"Standard Bagging": 0.02,
"PHF Horse Mixes": 0.01,
"Peckish": 0.08,
"Hay & Straw": 0.09,
},
client_margins={
"Specialty": {"distributor_margin": 0.2, "wholesale_margin": 0.1},
"Peckish": {"distributor_margin": 0.25, "wholesale_margin": 0.15},
"Hay & Straw": {"distributor_margin": 0.3, "wholesale_margin": 0.2},
"Straight Grain": {"distributor_margin": 0.1, "wholesale_margin": 0.08},
"PHF Horse Mixes": {"distributor_margin": 0.18, "wholesale_margin": 0.12},
},
bag_costs={
"20kg_bag": 0.6,
"bulka_bag": 22.0,
"own_bag_credit": 0.2,
"1_5kg_bagging": 0.35,
"peckish_bag": 0.4,
},
freight_costs={
"freight_per_pallet": 80.0,
"peckish_freight_per_pallet": 96.0,
"hay_straw_freight_per_pallet": 120.0,
},
)
def item(**overrides) -> ProductCostInputItem:
values = {
"client_category": "Specialty",
"product_name": "Pigeon Mix 20kg",
"mix_product_name": "Pigeon Mix",
"unit_type": "Standard",
"own_bag": None,
"unit_kg": 20.0,
"items_per_pallet": 40,
"bagging_process": "Bagging + Grading",
"manual_distributor_margin": None,
"manual_wholesale_margin": None,
}
values.update(overrides)
return ProductCostInputItem(**values)
def test_standard_product_uses_per_kg_components_unit_bag_freight_and_default_margins():
result = calculate_product_cost_item(item(), assumptions(), 0.5)
assert result.grading_cost_per_kg == 0.05
assert result.bagging_cost_per_kg == 0.04
assert result.bag_cost_per_unit == 0.6
assert result.freight_cost_per_unit == 2.0
assert result.finished_product_delivered_cost == 14.4
assert result.distributor_price == 18.0
assert result.wholesale_price == 16.0
assert result.warnings == []
def test_bulka_uses_per_kg_delivered_formula_and_bulka_bag_and_freight_divided_by_unit_kg():
result = calculate_product_cost_item(item(unit_type="Bulka", unit_kg=1000, items_per_pallet=1), assumptions(), 0.5)
assert result.bag_cost_per_unit == 0.022
assert result.freight_cost_per_unit == 0.08
assert result.finished_product_delivered_cost == 0.692
def test_per_unit_uses_per_kg_delivered_formula_but_standard_pallet_freight():
result = calculate_product_cost_item(item(unit_type="Per Unit", unit_kg=1, items_per_pallet=10), assumptions(), 0.5)
assert result.freight_cost_per_unit == 8.0
assert result.finished_product_delivered_cost == 8.59
def test_peckish_uses_peckish_bag_freight_and_zero_grading():
result = calculate_product_cost_item(
item(client_category="Peckish", bagging_process="Peckish", items_per_pallet=24),
assumptions(),
0.5,
)
assert result.grading_cost_per_kg == 0
assert result.bagging_cost_per_kg == 0.08
assert result.bag_cost_per_unit == 0.4
assert result.freight_cost_per_unit == 4.0
def test_hay_and_straw_uses_hay_freight_and_zero_grading():
result = calculate_product_cost_item(
item(client_category="Hay & Straw", bagging_process="Hay & Straw", items_per_pallet=30),
assumptions(),
0.5,
)
assert result.grading_cost_per_kg == 0
assert result.freight_cost_per_unit == 4.0
def test_phf_horse_mixes_have_zero_grading():
result = calculate_product_cost_item(
item(client_category="PHF Horse Mixes", bagging_process="PHF Horse Mixes"),
assumptions(),
0.5,
)
assert result.grading_cost_per_kg == 0
def test_own_bag_subtracts_credit_and_no_bag_sets_bag_cost_to_zero():
own_bag = calculate_product_cost_item(item(own_bag="Yes"), assumptions(), 0.5)
no_bag = calculate_product_cost_item(item(own_bag="No Bag"), assumptions(), 0.5)
assert own_bag.bag_cost_per_unit == 0.4
assert no_bag.bag_cost_per_unit == 0
def test_one_point_five_kg_branch_multiplies_pack_formula_by_eight():
result = calculate_product_cost_item(item(unit_type="1.5 kg", unit_kg=1.5), assumptions(), 0.5)
assert result.bag_cost_per_unit == 0.35
assert result.finished_product_delivered_cost == 10.84
def test_cracked_product_adds_cracking_cost_and_manual_margins_override_defaults():
result = calculate_product_cost_item(
item(product_name="Cracked Maize 20kg", manual_distributor_margin=0.1, manual_wholesale_margin=0.05),
assumptions(),
0.5,
)
assert result.cracking_cost_per_kg == 0.03
assert result.distributor_price == 16.6667
assert result.wholesale_price == 15.8
def test_straight_grain_bulka_wholesale_rounds_up_to_two_decimals():
result = calculate_product_cost_item(
item(client_category="Straight Grain", unit_type="Bulka", unit_kg=1000, items_per_pallet=1),
assumptions(),
0.5,
)
assert result.wholesale_price == 0.76
def test_missing_lookup_and_invalid_inputs_generate_warnings_without_prices():
result = calculate_product_cost_item(
item(unit_kg=None, items_per_pallet=0, manual_distributor_margin=1.2),
assumptions(),
None,
)
assert "Missing mix/product cost lookup" in result.warnings
assert "Missing unit kg" in result.warnings
assert "Missing pallet quantity" in result.warnings
assert "Invalid distributor margin" in result.warnings
assert result.finished_product_delivered_cost is None
assert result.distributor_price is None
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import create_engine, inspect
from sqlalchemy.pool import StaticPool
import app.models # noqa: F401 - import all model modules before reading metadata
from app.db.session import Base
def test_fresh_sqlite_schema_matches_model_metadata():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
inspector = inspect(engine)
actual_tables = set(inspector.get_table_names())
expected_tables = set(Base.metadata.tables)
assert actual_tables == expected_tables
for table_name, table in Base.metadata.tables.items():
actual_columns = {column["name"] for column in inspector.get_columns(table_name)}
expected_columns = {column.name for column in table.columns}
assert actual_columns == expected_columns
+1 -1
View File
@@ -6,7 +6,7 @@ services:
dockerfile: backend/Dockerfile
restart: unless-stopped
environment:
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
APP_NAME: ${APP_NAME:-Hunter App}
DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db}
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com}
+1 -1
View File
@@ -23,7 +23,7 @@ services:
dockerfile: backend/Dockerfile
restart: unless-stopped
environment:
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
APP_NAME: ${APP_NAME:-Hunter App}
APP_ENV: ${APP_ENV:-production}
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}}
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
+1 -1
View File
@@ -6,7 +6,7 @@ services:
dockerfile: backend/Dockerfile
restart: unless-stopped
environment:
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
APP_NAME: ${APP_NAME:-Hunter App}
APP_ENV: ${APP_ENV:-development}
DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db}
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hunter-app",
"version": "0.1.11b",
"version": "0.1.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hunter-app",
"version": "0.1.11b",
"version": "0.1.12",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hunter-app",
"version": "0.1.11b",
"version": "0.1.12",
"private": true,
"type": "module",
"scripts": {
+15
View File
@@ -4,6 +4,21 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.png" />
<script>
// Resolve the theme before first paint so there is no light-mode flash.
(function () {
try {
var pref = localStorage.getItem('theme');
var dark =
pref === 'dark' ||
((!pref || pref === 'system') &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
} catch (e) {
document.documentElement.dataset.theme = 'light';
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+39 -46
View File
@@ -1,16 +1,5 @@
import { env } from '$env/dynamic/public';
import { browser } from '$app/environment';
import {
mockClientAccess,
mockClientAccessExport,
mockCosts,
mockMixCalculatorOptions,
mockMixCalculatorSessions,
mockMixes,
mockProducts,
mockRawMaterials,
mockScenarios
} from '$lib/mock';
import type {
ClientAccessAccount,
ClientAccessPowerBiExport,
@@ -34,6 +23,9 @@ import type {
MixUpdateInput,
Product,
ProductCostBreakdown,
ProductCostingInputs,
ProductCostingItem,
ProductCostingItemUpdateInput,
RawMaterial,
RawMaterialCreateInput,
RawMaterialPriceCreateInput,
@@ -136,24 +128,18 @@ function normalizeRequestError(error: unknown) {
return new Error('An unexpected error occurred while contacting the server.');
}
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
async function fetchJson<T>(path: string, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
try {
const response = await fetcher(resolveRequestUrl(path, fetcher), {
credentials: 'include'
});
if (!response.ok) {
if (auth !== 'none') {
throw new Error(response.statusText || 'Unauthorized');
}
return fallback;
throw new Error(response.statusText || 'Request failed');
}
return (await response.json()) as T;
} catch (error) {
if (auth !== 'none') {
throw normalizeRequestError(error);
}
return fallback;
}
}
// In-memory GET cache with TTL + in-flight de-duplication. The cache key
@@ -172,13 +158,12 @@ function makeCacheKey(path: string, auth: AuthMode) {
async function cachedFetchJson<T>(
path: string,
fallback: T,
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<T> {
// Bypass the cache during SSR (no localStorage, no shared session).
if (!browser) {
return fetchJson<T>(path, fallback, auth, fetcher);
return fetchJson<T>(path, auth, fetcher);
}
const key = makeCacheKey(path, auth);
@@ -194,7 +179,7 @@ async function cachedFetchJson<T>(
return existing as Promise<T>;
}
const promise = fetchJson<T>(path, fallback, auth, fetcher)
const promise = fetchJson<T>(path, auth, fetcher)
.then((value) => {
responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS });
return value;
@@ -290,13 +275,13 @@ async function requestBlob(
}
export const api = {
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher),
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson<Mix[]>('/api/mixes', 'client', fetcher),
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorOptions: (fetcher?: ApiFetch) =>
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', 'client', fetcher),
mixCalculatorSessions: (fetcher?: ApiFetch) =>
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', 'client', fetcher),
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) =>
@@ -321,7 +306,7 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', 'client', fetcher),
editorProducts: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.q) search.set('q', params.q);
@@ -329,7 +314,7 @@ export const api = {
if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString();
const path = qs ? `/api/editor/products?${qs}` : '/api/editor/products';
return cachedFetchJson<EditorProductRow[]>(path, [], 'client', fetcher);
return cachedFetchJson<EditorProductRow[]>(path, 'client', fetcher);
},
updateEditorProduct: (productId: number, payload: EditorProductUpdateInput) =>
request<EditorProductRow>(`/api/editor/products/${productId}`, {
@@ -358,10 +343,28 @@ export const api = {
method: 'DELETE'
}, 'client'),
productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
productCostingItems: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostingItem[]>('/api/product-costing/items', 'client', fetcher),
productCostingItemsFresh: () =>
request<ProductCostingItem[]>(`/api/product-costing/items?_=${Date.now()}`, { method: 'GET' }, 'client'),
productCostingInputs: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostingInputs>('/api/product-costing/inputs', 'client', fetcher),
updateProductCostingInputs: (payload: Partial<ProductCostingInputs>) =>
request<ProductCostingInputs>('/api/product-costing/inputs', {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
updateProductCostingItem: (itemId: number, payload: ProductCostingItemUpdateInput) =>
request<ProductCostingItem>(`/api/product-costing/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
recalculateProductCosting: () =>
request<{ recalculated: number }>('/api/product-costing/recalculate-all', { method: 'POST' }, 'client'),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', 'client', fetcher),
throughputProducts: (fetcher?: ApiFetch) =>
cachedFetchJson<ThroughputProduct[]>('/api/throughput/products', [], 'client', fetcher),
cachedFetchJson<ThroughputProduct[]>('/api/throughput/products', 'client', fetcher),
throughputEntries: (params?: ThroughputEntryListParams, fetcher?: ApiFetch) => {
const search = new URLSearchParams();
if (params?.date_from) search.set('date_from', params.date_from);
@@ -372,7 +375,7 @@ export const api = {
if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString();
const path = qs ? `/api/throughput/entries?${qs}` : '/api/throughput/entries';
return cachedFetchJson<ThroughputEntry[]>(path, [], 'client', fetcher);
return cachedFetchJson<ThroughputEntry[]>(path, 'client', fetcher);
},
createThroughputEntry: (payload: ThroughputEntryCreateInput) =>
request<ThroughputEntry>('/api/throughput/entries', {
@@ -389,22 +392,12 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', 'manager', fetcher),
clientAccessExport: (fetcher?: ApiFetch) =>
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', 'client', fetcher),
dashboardSummary: (fetcher?: ApiFetch) =>
cachedFetchJson<DashboardSummary>(
'/api/dashboard/summary',
{
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] }
},
'client',
fetcher
),
cachedFetchJson<DashboardSummary>('/api/dashboard/summary', 'client', fetcher),
clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST',
@@ -163,10 +163,8 @@
min-height: 100vh;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
background:
radial-gradient(circle at top left, rgba(167, 217, 190, 0.22), transparent 34%),
linear-gradient(180deg, #f7f8f4 0%, #eef2ea 100%);
color: #203028;
background: var(--color-bg-app);
color: var(--color-text-primary);
}
.admin-sidebar {
+66 -470
View File
@@ -17,6 +17,7 @@
canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster,
canOpenProductCosting as sessionCanOpenProductCosting,
canOpenReporting as sessionCanOpenReporting,
canOpenSettings as sessionCanOpenSettings,
canOpenThroughput as sessionCanOpenThroughput,
@@ -28,6 +29,7 @@
import {
accessControlItem,
baseSearchItems,
buildClientNavEntries,
clientBreadcrumbs,
dashboardItem,
editorItem,
@@ -35,6 +37,7 @@
matchesRoute,
mixCalculatorItem,
pageTitle,
productCostingItem,
reportingItem,
throughputItem,
type FooterLink,
@@ -96,10 +99,27 @@
})
);
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : 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 navEntries = $derived(
buildClientNavEntries({
dashboard: visibleDashboardItem,
costing: [
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...visibleWorkingDocumentItems
],
throughput: visibleThroughputItem,
reporting: visibleReportingItem
})
);
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([
@@ -112,6 +132,7 @@
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...visibleWorkingDocumentItems.slice(0, 2)
]
);
@@ -124,6 +145,7 @@
if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/mixes/new') return canCreateMixWorksheet;
if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession);
if (item.href === '/editor') return canOpenEditor;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings;
@@ -377,15 +399,8 @@
{#if !showBottomNav}
<ClientPrimaryRail
currentPath={shellPathname}
primaryItems={[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleThroughputItem ? [visibleThroughputItem] : []),
...(visibleReportingItem ? [visibleReportingItem] : [])
]}
entries={navEntries}
brandHref={workspaceHomeHref}
workingDocumentItems={visibleWorkingDocumentItems}
footerItems={visibleFooterLinks}
{appVersion}
{currentYear}
@@ -528,6 +543,15 @@
</a>
{/if}
{#if visibleProductCostingItem}
{@const Icon = visibleProductCostingItem.icon}
<a class:active={matchesRoute(visibleProductCostingItem.href, page.url.pathname)} href={visibleProductCostingItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleProductCostingItem.label}</span>
{#if visibleProductCostingItem.badge}<span class="drawer-badge">{visibleProductCostingItem.badge}</span>{/if}
</a>
{/if}
{#if visibleThroughputItem}
{@const Icon = visibleThroughputItem.icon}
<a class:active={matchesRoute(visibleThroughputItem.href, page.url.pathname)} href={visibleThroughputItem.href} onclick={() => (navOpen = false)}>
@@ -659,433 +683,9 @@
{/if}
<style>
:global(:root) {
/* ── Brand ──────────────────────────────────────────────── */
--color-brand: oklch(0.54 0.15 149);
--color-brand-hover: oklch(0.47 0.14 149);
--color-brand-tint: oklch(0.98 0.02 149);
/* ── Surfaces ───────────────────────────────────────────── */
--color-bg-app: oklch(0.975 0.006 150);
--color-bg-surface: oklch(0.997 0.004 150);
--color-bg-elevated: oklch(0.99 0.005 150);
/* ── Borders ────────────────────────────────────────────── */
--color-border: oklch(0.905 0.012 150);
--color-divider: oklch(0.935 0.009 150);
/* ── Text ───────────────────────────────────────────────── */
--color-text-primary: oklch(0.26 0.015 150);
--color-text-secondary: oklch(0.44 0.018 150);
--color-text-muted: oklch(0.62 0.018 150);
/* ── Semantic ───────────────────────────────────────────── */
--color-success: #1a7f37;
--color-warning: #bf8700;
--color-error: #cf222e;
--color-info: #0969da;
--color-warning-tint: oklch(0.975 0.035 78);
--color-info-tint: oklch(0.97 0.025 230);
/* ── Legacy aliases (keep old token names working) ───────── */
--bg: var(--color-bg-app);
--panel: var(--color-bg-surface);
--panel-soft: var(--color-bg-app);
--line: var(--color-border);
--line-strong: var(--color-border);
--text: var(--color-text-primary);
--muted: var(--color-text-muted);
--green: var(--color-brand);
--green-deep: oklch(0.25 0.018 150);
--green-soft: var(--color-brand-tint);
--blue-soft: var(--color-info-tint);
--shadow: none; /* flat design — use borders, not shadows */
--radius-panel: 1.2rem;
--radius-control: 0.82rem;
--radius-row: 0.95rem;
--space-page: 1.25rem;
--space-card: 1.15rem;
}
:global(html, body) {
margin: 0;
min-height: 100%;
background: var(--color-bg-app);
color: var(--color-text-primary);
font-family: "Inter", "Segoe UI", sans-serif;
font-size: 14px;
}
:global(*) {
box-sizing: border-box;
}
:global(h1, h2, h3, h4, h5, h6) {
font-family: "Inter", "Segoe UI", sans-serif;
letter-spacing: -0.03em;
}
:global(button),
:global(input),
:global(select),
:global(textarea) {
font: inherit;
}
:global(a) {
color: inherit;
text-decoration: none;
}
:global(:focus-visible) {
outline: 3px solid color-mix(in srgb, var(--color-brand) 38%, transparent);
outline-offset: 2px;
}
:global(.ui-stack) {
display: grid;
gap: var(--space-page);
}
:global(.ui-panel),
:global(.ui-metric-card) {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
box-shadow: var(--shadow);
}
:global(.ui-panel) {
padding: var(--space-card);
}
:global(.ui-panel-soft) {
background: var(--panel-soft);
border: 1px solid var(--line);
border-radius: var(--radius-row);
}
:global(.ui-section-heading) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
margin-bottom: 1rem;
}
:global(.ui-section-heading h3),
:global(.ui-section-heading h4) {
margin: 0.18rem 0 0;
font-size: 1.06rem;
font-weight: 700;
letter-spacing: 0;
}
:global(.ui-eyebrow) {
margin: 0;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
:global(.ui-muted) {
color: var(--muted);
}
:global(.ui-metric-row) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
:global(.ui-metric-card) {
padding: 1.05rem 1.1rem;
}
:global(.ui-metric-card span) {
display: block;
color: var(--muted);
font-size: 0.84rem;
}
:global(.ui-metric-card strong) {
display: block;
margin: 0.5rem 0 0.28rem;
font-size: 1.75rem;
font-weight: 700;
}
:global(.ui-metric-card p) {
margin: 0;
color: var(--muted);
}
:global(.ui-button) {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.6rem;
padding: 0.72rem 0.9rem;
border-radius: var(--radius-control);
font-weight: 600;
cursor: pointer;
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1), border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), color 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
:global(.ui-button.primary) {
border: 1px solid var(--color-brand);
color: oklch(0.99 0.004 150);
background: var(--color-brand);
}
:global(.ui-button.primary:hover:not(:disabled)) {
background: var(--color-brand-hover);
border-color: var(--color-brand-hover);
}
:global(.ui-button.secondary) {
border: 1px solid var(--line-strong);
color: var(--text);
background: var(--panel);
}
:global(.ui-button.secondary:hover:not(:disabled)) {
background: var(--panel-soft);
}
:global(.ui-button:disabled) {
opacity: 0.55;
cursor: not-allowed;
}
:global(.ui-pill) {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
border-radius: 999px;
padding: 0.4rem 0.74rem;
font-size: 0.82rem;
font-weight: 600;
text-transform: capitalize;
white-space: nowrap;
}
:global(.ui-pill.positive) {
color: var(--green-deep);
background: var(--green-soft);
}
:global(.ui-pill.warning) {
color: oklch(0.45 0.11 69);
background: var(--color-warning-tint);
}
:global(.ui-pill.neutral) {
color: var(--color-text-secondary);
background: color-mix(in srgb, var(--panel-soft) 74%, var(--panel));
}
:global(.ui-table-wrap) {
overflow-x: auto;
}
:global(.ui-table) {
width: 100%;
min-width: 48rem;
border-collapse: separate;
border-spacing: 0 0.65rem;
}
:global(.ui-table th),
:global(.ui-table td) {
padding: 0.9rem 0.95rem;
text-align: left;
white-space: nowrap;
}
:global(.ui-table th) {
color: var(--muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
:global(.ui-table tbody td) {
background: var(--panel-soft);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
:global(.ui-table tbody td:first-child) {
border-left: 1px solid var(--line);
border-radius: var(--radius-row) 0 0 var(--radius-row);
}
:global(.ui-table tbody td:last-child) {
border-right: 1px solid var(--line);
border-radius: 0 var(--radius-row) var(--radius-row) 0;
}
:global(.ui-table-identity) {
display: flex;
align-items: center;
gap: 0.74rem;
min-width: 0;
}
:global(.ui-row-mark) {
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.76rem;
color: oklch(0.99 0.004 150);
background: var(--green-deep);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
}
:global(.ui-table-identity strong),
:global(.ui-number-block strong) {
display: block;
font-size: 0.94rem;
}
:global(.ui-table-identity span),
:global(.ui-number-block span) {
display: block;
margin-top: 0.16rem;
color: var(--muted);
font-size: 0.8rem;
}
:global(.ui-number-block) {
display: grid;
gap: 0.08rem;
}
:global(.ui-form-grid) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
:global(.ui-form-grid.compact) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
:global(.ui-field) {
display: grid;
gap: 0.36rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
font-weight: 600;
}
:global(.ui-field input),
:global(.ui-field textarea),
:global(.ui-field select) {
width: 100%;
padding: 0.82rem 0.9rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-control);
background: var(--panel-soft);
color: var(--text);
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1), border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
:global(.ui-field input:focus),
:global(.ui-field textarea:focus),
:global(.ui-field select:focus) {
outline: none;
border-color: var(--color-brand);
background: var(--panel);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
@media (max-width: 980px) {
:global(.ui-metric-row) {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
:global(.ui-section-heading) {
flex-direction: column;
align-items: flex-start;
}
:global(.ui-table) {
min-width: 0;
border-spacing: 0;
}
:global(.ui-table),
:global(.ui-table thead),
:global(.ui-table tbody),
:global(.ui-table tr),
:global(.ui-table td) {
display: block;
width: 100%;
}
:global(.ui-table thead) {
display: none;
}
:global(.ui-table tbody) {
display: grid;
gap: 0.85rem;
}
:global(.ui-table tbody tr) {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: var(--radius-row);
background: var(--panel-soft);
}
:global(.ui-table tbody td) {
padding: 0.76rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
:global(.ui-table tbody td:first-child),
:global(.ui-table tbody td:last-child) {
border: none;
border-radius: 0;
}
:global(.ui-table tbody td + td) {
border-top: 1px solid var(--line);
}
:global(.ui-table tbody td::before) {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
:global(.ui-form-grid),
:global(.ui-form-grid.compact) {
grid-template-columns: 1fr;
}
}
/* Design tokens and the shared .ui-* surface classes now live in
$lib/styles/theme.css (imported once in the root layout) so every shell,
client / admin / error, themes consistently in light and dark. */
.app-shell {
display: grid;
@@ -1126,7 +726,7 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #6d7d74;
color: var(--color-text-muted);
background: transparent;
border-radius: 0.55rem;
width: 1.6rem;
@@ -1210,13 +810,13 @@
}
.search-placeholder {
color: #93a098;
color: var(--color-text-muted);
}
.search-icon {
width: 0.82rem;
height: 0.82rem;
border: 2px solid #98a59d;
border: 2px solid var(--color-text-muted);
border-radius: 999px;
}
@@ -1228,7 +828,7 @@
width: 0.42rem;
height: 2px;
border-radius: 999px;
background: #98a59d;
background: var(--color-text-muted);
transform: rotate(45deg);
}
@@ -1237,7 +837,7 @@
border: 1px solid var(--line-strong);
border-radius: 0.42rem;
color: var(--muted);
background: #fff;
background: var(--color-bg-surface);
font-size: 0.76rem;
}
@@ -1257,7 +857,7 @@
border: none;
border-radius: 0.82rem;
background: transparent;
color: #304038;
color: var(--color-text-primary);
cursor: pointer;
transition: background-color 160ms ease;
}
@@ -1301,8 +901,8 @@
/* `.nav-icon.muted` is kept for the bottom-nav (still uses letter labels). */
.nav-icon.muted {
color: #fff;
background: linear-gradient(135deg, #95a39b 0%, #6e7c73 100%);
color: var(--color-on-brand);
background: var(--color-brand);
}
.main-shell {
@@ -1334,9 +934,8 @@
padding: 0.4rem;
border: 1px solid var(--color-border);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
backdrop-filter: blur(10px);
background: var(--color-bg-elevated);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.quick-fab-wrap {
@@ -1357,7 +956,7 @@
border: none;
border-radius: 999px;
background: var(--color-brand);
color: #fff;
color: var(--color-on-brand);
box-shadow: none;
font-weight: 700;
letter-spacing: 0.01em;
@@ -1408,7 +1007,7 @@
.menu-panel button {
padding: 0.72rem 0.78rem;
border-radius: 0.78rem;
color: #304038;
color: var(--color-text-primary);
text-align: left;
background: transparent;
border: none;
@@ -1585,11 +1184,10 @@
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
padding: 0.6rem;
border: 1px solid rgba(217, 228, 221, 0.92);
border: 1px solid var(--color-border);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
backdrop-filter: blur(16px);
background: var(--color-bg-surface);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.quick-fab-wrap {
@@ -1606,7 +1204,7 @@
border: none;
border-radius: 1rem;
background: transparent;
color: #51635a;
color: var(--color-text-secondary);
text-align: center;
font-size: 0.74rem;
font-weight: 700;
@@ -1626,14 +1224,14 @@
align-items: center;
justify-content: center;
border-radius: 0.78rem;
color: #fff;
background: var(--green-deep);
color: var(--color-on-brand);
background: var(--color-brand);
font-size: 0.66rem;
letter-spacing: 0.04em;
}
.bottom-nav-icon.muted {
background: #8b949e;
background: var(--color-text-muted);
}
.bottom-drawer {
@@ -1647,10 +1245,8 @@
padding: 0.85rem 1rem calc(6.6rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
border-radius: 1.6rem 1.6rem 0 0;
background:
linear-gradient(180deg, rgba(248, 251, 249, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);
box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
backdrop-filter: blur(16px);
background: var(--color-bg-surface);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
.drawer-handle {
@@ -1658,7 +1254,7 @@
height: 0.34rem;
margin: 0 auto;
border-radius: 999px;
background: #c8d4ce;
background: var(--color-border);
}
.drawer-header {
@@ -1673,7 +1269,7 @@
}
:global(.drawer-search) {
background: #fff;
background: var(--color-bg-surface);
}
.drawer-grid {
@@ -1695,23 +1291,23 @@
padding: 0.82rem 0.86rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.88);
color: #304038;
background: var(--color-bg-surface);
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
}
.drawer-section a.active {
color: var(--green-deep);
background: var(--green-soft);
color: var(--color-brand-hover);
background: color-mix(in srgb, var(--color-brand) 11%, var(--color-bg-surface));
}
.drawer-badge {
margin-left: auto;
padding: 0.08rem 0.4rem;
border-radius: 999px;
background: #fdf0d2;
color: #8a5a00;
background: var(--color-warning-tint);
color: var(--color-warning-text);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.06em;
@@ -1747,8 +1343,8 @@
padding: 0.82rem 0.9rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.88);
color: #304038;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-weight: 600;
}
@@ -62,8 +62,8 @@
<div><span>Total bags</span><strong>{formatNumber(session.total_bags, 2)}</strong><small>{session.product_unit_of_measure}</small></div>
<div><span>Prepared by</span><strong>{session.prepared_by_name}</strong><small>{formatDate(session.mix_date)}</small></div>
<div><span>Client</span><strong>{session.client_name}</strong></div>
<div><span>Product</span><strong>{session.product_name}</strong></div>
<div><span>Mix source</span><strong>{session.mix_name}</strong></div>
<div><span>Mix</span><strong>{session.product_name}</strong></div>
<div><span>Formula source</span><strong>{session.mix_name}</strong></div>
<div><span>Unit size</span><strong>{formatNumber(session.product_unit_size_kg, 2)}kg</strong></div>
<div><span>Batch size</span><strong>{formatNumber(session.batch_size_kg, 2)}kg</strong></div>
</section>
@@ -1,23 +1,62 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { api } from '$lib/api';
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
import type { MixCalculatorSession } from '$lib/types';
let { session }: { session: MixCalculatorSession } = $props();
let { session, autoPrint = true }: { session: MixCalculatorSession; autoPrint?: boolean } = $props();
let pdfUrl = $state<string | null>(null);
let loading = $state(true);
let error = $state('');
let printAfterLoad = $state(false);
let pdfFrame = $state<HTMLIFrameElement | null>(null);
const printableTitle = $derived(
`MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_')
);
async function openPdf() {
function revokePdfUrl() {
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
pdfUrl = null;
}
}
async function loadPdf() {
loading = true;
error = '';
revokePdfUrl();
try {
const blob = await api.mixCalculatorSessionPdf(session.id);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener,noreferrer');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
pdfUrl = URL.createObjectURL(blob);
printAfterLoad = autoPrint;
} catch (loadError) {
error = loadError instanceof Error ? loadError.message : 'Unable to load the PDF preview.';
} finally {
loading = false;
}
}
function printPage() {
if (!pdfUrl) {
printAfterLoad = true;
loadPdf();
return;
}
pdfFrame?.contentWindow?.focus();
pdfFrame?.contentWindow?.print();
}
function handlePreviewLoaded() {
if (printAfterLoad) {
printAfterLoad = false;
requestAnimationFrame(() => printPage());
}
}
async function downloadPdf() {
const blob = await api.mixCalculatorSessionPdf(session.id);
const blob = pdfUrl ? await fetch(pdfUrl).then((response) => response.blob()) : await api.mixCalculatorSessionPdf(session.id);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
@@ -27,6 +66,14 @@
anchor.remove();
URL.revokeObjectURL(url);
}
onMount(() => {
loadPdf();
});
onDestroy(() => {
revokePdfUrl();
});
</script>
<svelte:head>
@@ -36,11 +83,28 @@
<section class="print-page">
<div class="print-toolbar">
<a class="secondary-button" href={`/mix-calculator/${session.id}`}>Back to session</a>
<button class="primary-button" type="button" onclick={openPdf}>Open Styled PDF</button>
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
<button class="primary-button" type="button" disabled={!pdfUrl && loading} onclick={printPage}>Print</button>
<button class="secondary-button" type="button" disabled={!pdfUrl && loading} onclick={downloadPdf}>Download PDF</button>
</div>
<MixCalculatorPrintDocument session={session} generatedAt={new Date().toISOString()} />
<div class="pdf-preview-shell">
{#if loading}
<div class="preview-state">Loading PDF preview...</div>
{:else if error}
<div class="preview-state error">
<strong>PDF preview unavailable</strong>
<span>{error}</span>
<button class="secondary-button" type="button" onclick={loadPdf}>Retry</button>
</div>
{:else if pdfUrl}
<iframe
bind:this={pdfFrame}
src={pdfUrl}
title={`${printableTitle} PDF preview`}
onload={handlePreviewLoaded}
></iframe>
{/if}
</div>
</section>
<style>
@@ -57,6 +121,7 @@
display: flex;
justify-content: flex-end;
gap: 0.75rem;
width: min(100%, 210mm);
}
.primary-button,
@@ -82,6 +147,52 @@
color: #304038;
}
button:disabled {
cursor: wait;
opacity: 0.65;
}
.pdf-preview-shell {
width: min(100%, 210mm);
aspect-ratio: 210 / 297;
border: 1px solid var(--line-strong);
border-radius: 0.75rem;
background: #fff;
overflow: hidden;
box-shadow: 0 18px 50px rgba(25, 35, 30, 0.16);
}
iframe {
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
.preview-state {
display: grid;
place-items: center;
gap: 0.75rem;
height: 100%;
padding: 2rem;
color: var(--color-text-secondary);
text-align: center;
font-weight: 600;
}
.preview-state.error {
align-content: center;
}
.preview-state strong,
.preview-state span {
display: block;
}
.preview-state strong {
color: var(--color-text-primary);
}
@media (max-width: 900px) {
.print-toolbar {
justify-content: stretch;
@@ -93,16 +204,9 @@
}
@media print {
:global(body) {
background: #fff;
}
.print-page {
padding: 0;
background: #fff;
}
.print-toolbar {
.print-page,
.print-toolbar,
.pdf-preview-shell {
display: none;
}
}
@@ -0,0 +1,42 @@
<script lang="ts">
import { Moon, Sun } from 'lucide-svelte';
import { resolvedTheme, toggleTheme } from '$lib/theme';
const isDark = $derived($resolvedTheme === 'dark');
</script>
<button
class="theme-toggle"
type="button"
onclick={toggleTheme}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{#if isDark}
<Sun size={18} strokeWidth={1.75} />
{:else}
<Moon size={18} strokeWidth={1.75} />
{/if}
</button>
<style>
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
flex-shrink: 0;
border: 1px solid var(--color-border);
border-radius: 0.82rem;
background: var(--color-bg-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.theme-toggle:hover {
background: var(--color-surface-hover);
color: var(--color-text-primary);
}
</style>
@@ -1,7 +1,8 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { api } from '$lib/api';
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
import { featureFlags } from '$lib/features';
import { formatNumber } from '$lib/format';
import { clientSession, hasModuleAccess } from '$lib/session';
import { toast } from '$lib/toast';
import type {
@@ -52,8 +53,11 @@
let notes = $state(initialNotesValue());
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
let formError = $state('');
let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock products.');
let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock mixes.');
let previewLoading = $state(false);
let printPdfUrl = $state<string | null>(null);
let printFrame = $state<HTMLIFrameElement | null>(null);
let printAfterPdfLoad = $state(false);
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
const availableClients = $derived(
@@ -91,14 +95,6 @@
}
});
function formatNumber(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return value.toFixed(digits);
}
function buildPayload(): MixCalculatorCreateInput | null {
formError = '';
formHint = '';
@@ -115,13 +111,13 @@
return null;
}
if (!clientName) {
formError = 'Select a client to unlock matching products.';
formHint = 'Products stay disabled until a client is selected.';
formError = 'Select a client to unlock matching mixes.';
formHint = 'Mixes stay disabled until a client is selected.';
return null;
}
if (!productId) {
formError = 'Select a product.';
formHint = 'Pick one of the products available for the selected client.';
formError = 'Select a mix.';
formHint = 'Pick one of the mixes available for the selected client.';
return null;
}
if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) {
@@ -171,26 +167,26 @@
notes = '';
preview = null;
formError = '';
formHint = 'Select a mix date and prepared by name, then choose a client to unlock products.';
formHint = 'Select a mix date and prepared by name, then choose a client to unlock mixes.';
}
$effect(() => {
if (!clientName) {
formHint = 'Select a client to unlock the product list.';
formHint = 'Select a client to unlock the mix list.';
return;
}
if (!filteredProducts.length) {
formHint = `No products are available for ${clientName}.`;
formHint = `No mixes are available for ${clientName}.`;
return;
}
if (!productId) {
formHint = 'Select a product for the chosen client.';
formHint = 'Select a mix for the chosen client.';
return;
}
formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected product'}.`;
formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected mix'}.`;
});
async function downloadPdf() {
@@ -219,27 +215,53 @@
}
}
async function openPdf() {
const tid = toast.loading('Opening styled PDF…');
try {
function revokePrintPdfUrl() {
if (printPdfUrl) {
URL.revokeObjectURL(printPdfUrl);
printPdfUrl = null;
}
}
async function printCurrent() {
if (!preview) {
toast.error('Calculate the mix before printing.');
return;
}
const payload = buildPayload();
if (!payload) {
toast.dismiss(tid);
toast.error(formError || 'Complete the mix details first.');
return;
}
const blob = await api.previewMixCalculatorPdf(payload);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener,noreferrer');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
const tid = toast.loading('Preparing print...');
try {
const blob = initialSession ? await api.mixCalculatorSessionPdf(initialSession.id) : await api.previewMixCalculatorPdf(payload);
revokePrintPdfUrl();
printAfterPdfLoad = true;
printPdfUrl = URL.createObjectURL(blob);
toast.dismiss(tid);
} catch (error) {
toast.dismiss(tid);
toast.error(error instanceof Error ? error.message : 'Unable to open the styled PDF.');
toast.error(error instanceof Error ? error.message : 'Unable to prepare the PDF for printing.');
}
}
function printLoadedPdf() {
printFrame?.contentWindow?.focus();
printFrame?.contentWindow?.print();
}
function handlePrintFrameLoad() {
if (printAfterPdfLoad) {
printAfterPdfLoad = false;
requestAnimationFrame(printLoadedPdf);
}
}
onDestroy(() => {
revokePrintPdfUrl();
});
</script>
{#if !canEdit && !initialSession}
@@ -258,8 +280,7 @@
<a class="secondary-button" href="/mix-calculator">Session history</a>
{/if}
{#if initialSession}
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Open PDF page</a>
<button class="primary-button" type="button" onclick={openPdf}>Open PDF in new tab</button>
<a class="primary-button" href={`/mix-calculator/${initialSession.id}/print`}>Print</a>
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
{/if}
</section>
@@ -270,7 +291,7 @@
<div class="section-header">
<div>
<h3>Session Inputs</h3>
<p>Batch size drives the scale factor. Total bags are derived from the selected product unit size.</p>
<p>Batch size drives the scale factor. Total bags are derived from the selected mix unit size.</p>
</div>
{#if selectedProduct}
<div class="product-pill">
@@ -301,7 +322,7 @@
<label>
<span>Client</span>
<select bind:value={clientName} disabled={!canEdit} title="Select a client to unlock matching products.">
<select bind:value={clientName} disabled={!canEdit} title="Select a client to unlock matching mixes.">
<option value="">Select a client</option>
{#each availableClients as client}
<option value={client}>{client}</option>
@@ -310,16 +331,16 @@
</label>
<label>
<span>Product</span>
<span>Mix Name</span>
<select
bind:value={productId}
disabled={!canEdit || !clientName || !filteredProducts.length}
title={!clientName ? 'Select a client first.' : !filteredProducts.length ? 'No products are available for the selected client.' : 'Select a product.'}
title={!clientName ? 'Select a client first.' : !filteredProducts.length ? 'No mixes are available for the selected client.' : 'Select a mix.'}
>
<option value={0}>Select a product</option>
<option value={0}>Select a mix</option>
{#each filteredProducts as product}
<option value={product.product_id}>
{product.product_name} · {product.mix_name} · {product.unit_of_measure}
{product.product_name}
</option>
{/each}
</select>
@@ -338,8 +359,8 @@
{#if canEdit && selectedProduct}
<div class="calculation-note">
<strong>Source mix</strong>
<span>{selectedProduct.mix_name} totals {formatNumber(selectedProduct.mix_total_kg, 2)}kg. Scale factor = batch size / source mix total.</span>
<strong>Formula source</strong>
<span>{selectedProduct.product_name} totals {formatNumber(selectedProduct.mix_total_kg, 2)}kg. Scale factor = batch size / source formula total.</span>
</div>
{/if}
@@ -361,15 +382,19 @@
<MixCalculatorResultsPanel
preview={preview}
sessionNumber={initialSession?.session_number ?? null}
onOpenPdf={preview ? openPdf : null}
onPrint={preview ? printCurrent : null}
onDownloadPdf={preview ? downloadPdf : null}
/>
</section>
{#if preview}
<section class="print-only" aria-hidden="true">
<MixCalculatorPrintDocument session={preview} />
</section>
{#if printPdfUrl}
<iframe
bind:this={printFrame}
class="print-pdf-frame"
src={printPdfUrl}
title="Mix calculator PDF print frame"
onload={handlePrintFrameLoad}
></iframe>
{/if}
{/if}
@@ -636,33 +661,14 @@
}
}
.print-only {
display: none;
}
@media print {
:global(body) {
background: #fff !important;
margin: 0 !important;
}
:global(body *) {
visibility: hidden !important;
}
.print-only,
.print-only :global(*) {
visibility: visible !important;
}
.print-only {
display: block;
position: absolute;
inset: 0;
padding: 0;
background: #fff;
color: #1a2421;
font-family: inherit;
}
.print-pdf-frame {
position: fixed;
right: 0;
bottom: 0;
width: 1px;
height: 1px;
border: 0;
opacity: 0;
pointer-events: none;
}
</style>
@@ -1,32 +1,20 @@
<script lang="ts">
import { Download, Printer } from 'lucide-svelte';
import { formatDate, formatNumber } from '$lib/format';
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
let {
preview,
sessionNumber = null,
onOpenPdf = null,
onPrint = null,
onDownloadPdf = null
}: {
preview: MixCalculatorPreview | MixCalculatorSession | null;
sessionNumber?: string | null;
onOpenPdf?: (() => void) | null;
onPrint?: (() => void) | null;
onDownloadPdf?: (() => void) | null;
} = $props();
function formatDate(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium'
}).format(new Date(value));
}
function formatNumber(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return value.toFixed(digits);
}
</script>
<article class="result-card">
@@ -76,11 +64,11 @@
<strong>{preview.client_name}</strong>
</div>
<div>
<span>Product</span>
<span>Mix</span>
<strong>{preview.product_name}</strong>
</div>
<div>
<span>Mix source</span>
<span>Formula source</span>
<strong>{preview.mix_name}</strong>
</div>
<div>
@@ -111,7 +99,7 @@
</div>
<div class="output-actions">
<button class="primary-button" disabled={!onOpenPdf} type="button" onclick={() => onOpenPdf?.()}>
<button class="primary-button" disabled={!onPrint} type="button" onclick={() => onPrint?.()}>
<Printer size={18} strokeWidth={1.9} aria-hidden="true" />
Print
</button>
@@ -132,7 +120,7 @@
<span></span><span></span><span></span>
</div>
<strong>No calculation yet</strong>
<span>Choose a client, product, date, and batch size on the left, then click Calculate mix.</span>
<span>Choose a client, mix, date, and batch size on the left, then click Calculate mix.</span>
</div>
<div class="empty-shimmer-rows">
{#each [1,2,3,4,5] as _}
@@ -73,7 +73,7 @@
border: none;
border-radius: 0.7rem;
background: transparent;
color: var(--nav-item-color, #3a4a41);
color: var(--nav-item-color, var(--color-text-secondary));
font-size: var(--nav-item-size, 0.93rem);
font-weight: var(--nav-item-weight, 500);
text-align: left;
@@ -83,14 +83,14 @@
.nav-list a:hover,
.nav-button:hover {
background: var(--nav-item-hover-bg, var(--panel-soft));
color: var(--nav-item-hover-color, #304038);
background: var(--nav-item-hover-bg, var(--color-surface-hover));
color: var(--nav-item-hover-color, var(--color-text-primary));
}
.nav-list a.active,
.nav-button.active {
background: var(--nav-item-active-bg, var(--color-brand));
color: var(--nav-item-active-color, #fff);
color: var(--nav-item-active-color, var(--color-on-brand));
font-weight: var(--nav-item-active-weight, 600);
}
@@ -111,8 +111,8 @@
flex-shrink: 0;
padding: 0.08rem 0.4rem;
border-radius: 999px;
background: #fdf0d2;
color: #8a5a00;
background: var(--color-warning-tint);
color: var(--color-warning-text);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.06em;
@@ -122,8 +122,8 @@
.nav-list a.active .nav-badge,
.nav-button.active .nav-badge {
background: rgba(255, 255, 255, 0.92);
color: #8a5a00;
background: var(--color-warning-tint);
color: var(--color-warning-text);
}
.nav-icon {
@@ -133,18 +133,18 @@
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
color: var(--nav-icon-color, #6d7d74);
color: var(--nav-icon-color, var(--color-text-muted));
border-radius: 0.55rem;
transition: color 140ms ease;
}
.nav-list a:hover .nav-icon,
.nav-button:hover .nav-icon {
color: var(--nav-icon-hover-color, #304038);
color: var(--nav-icon-hover-color, var(--color-text-secondary));
}
.nav-list a.active .nav-icon,
.nav-button.active .nav-icon {
color: var(--nav-icon-active-color, #fff);
color: var(--nav-icon-active-color, var(--color-on-brand));
}
</style>
@@ -75,23 +75,23 @@
height: 100%;
min-height: calc(100vh - 8.5rem);
padding: 0;
background: color-mix(in srgb, var(--panel-soft) 72%, white);
background: color-mix(in srgb, var(--panel-soft) 60%, var(--color-bg-surface));
border-right: 1px solid var(--line);
overflow-y: auto;
--nav-section-label-color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
--nav-section-label-color: var(--color-text-muted);
--nav-section-label-size: 0.66rem;
--nav-section-label-spacing: 0.14em;
--nav-item-color: #66756d;
--nav-item-color: var(--color-text-secondary);
--nav-item-size: 0.88rem;
--nav-item-weight: 450;
--nav-item-hover-bg: color-mix(in srgb, var(--panel) 72%, transparent);
--nav-item-hover-color: #425148;
--nav-item-active-bg: color-mix(in srgb, var(--color-brand) 7%, transparent);
--nav-item-active-color: #22352d;
--nav-item-hover-bg: var(--color-surface-hover);
--nav-item-hover-color: var(--color-text-primary);
--nav-item-active-bg: color-mix(in srgb, var(--color-brand) 11%, transparent);
--nav-item-active-color: var(--color-brand-hover);
--nav-item-active-weight: 560;
--nav-item-active-marker: color-mix(in srgb, var(--color-brand) 28%, transparent);
--nav-icon-color: #8a9790;
--nav-icon-hover-color: #607067;
--nav-item-active-marker: color-mix(in srgb, var(--color-brand) 32%, transparent);
--nav-icon-color: var(--color-text-muted);
--nav-icon-hover-color: var(--color-text-secondary);
--nav-icon-active-color: var(--color-brand);
}
@@ -1,14 +1,19 @@
<script lang="ts">
import { LogOut, Settings } from 'lucide-svelte';
import { ChevronDown, LogOut, Settings } from 'lucide-svelte';
import type { ComponentType } from 'svelte';
import AppNavSection from '$lib/components/navigation/AppNavSection.svelte';
import { matchesRoute, type FooterLink, type NavItem } from '$lib/navigation/client-navigation';
import {
groupHasActiveChild,
matchesRoute,
type FooterLink,
type NavEntry,
type NavItem
} from '$lib/navigation/client-navigation';
let {
brandHref,
currentPath,
primaryItems,
workingDocumentItems,
entries,
footerItems,
appVersion,
currentYear,
@@ -18,8 +23,7 @@
}: {
brandHref: string;
currentPath: string;
primaryItems: NavItem[];
workingDocumentItems: NavItem[];
entries: NavEntry[];
footerItems: FooterLink[];
appVersion: string;
currentYear: number;
@@ -27,76 +31,156 @@
onOpenSettings: () => void;
onSignOut: () => void;
} = $props();
// ── Collapse state ──────────────────────────────────────────────
// Smart auto-expand: the group holding the current page opens itself, other
// groups stay exactly as the user left them, and the whole map survives a
// reload through sessionStorage. Multiple groups may be open at once.
const STORAGE_KEY = 'hsf:nav:open-groups';
function restoreOpenState(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
try {
return JSON.parse(window.sessionStorage.getItem(STORAGE_KEY) ?? '{}');
} catch {
return {};
}
}
let openGroups = $state<Record<string, boolean>>(restoreOpenState());
let lastAutoExpanded = $state<string | null>(null);
function persistOpenState() {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(openGroups));
} catch {
// Private-mode storage failures shouldn't break navigation.
}
}
const activeGroupId = $derived.by(() => {
for (const entry of entries) {
if (entry.kind === 'group' && groupHasActiveChild(entry.group, currentPath)) {
return entry.group.id;
}
}
return null;
});
// Open the active group once each time it changes. Because this only fires on
// a *change* of activeGroupId, a user who manually closes the group they're
// standing in won't have it reopened under them.
$effect(() => {
const id = activeGroupId;
if (id && lastAutoExpanded !== id) {
if (!openGroups[id]) {
openGroups[id] = true;
persistOpenState();
}
lastAutoExpanded = id;
}
});
const isOpen = (id: string) => openGroups[id] ?? false;
function toggleGroup(id: string) {
openGroups[id] = !isOpen(id);
persistOpenState();
}
const moduleCount = $derived.by(() =>
entries.reduce((count, entry) => count + (entry.kind === 'item' ? 1 : entry.group.children.length), 0)
);
</script>
{#snippet leafLink(item: NavItem, showIcon: boolean)}
{@const Icon = item.icon}
<a class="rail-row" class:active={matchesRoute(item.href, currentPath)} href={item.href}>
{#if showIcon && Icon}
<span class="rail-icon"><Icon size={18} strokeWidth={1.75} /></span>
{/if}
<span class="rail-text">{item.label}</span>
{#if item.badge}<span class="rail-badge">{item.badge}</span>{/if}
</a>
{/snippet}
{#snippet actionRow(label: string, Icon: ComponentType, active: boolean, onSelect: () => void)}
{@const RowIcon = Icon}
<button type="button" class="rail-row" class:active onclick={onSelect}>
<span class="rail-icon"><RowIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{label}</span>
</button>
{/snippet}
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href={brandHref}>
<img class="sidebar-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
<span class="brand-kicker">Hunter App</span>
<span class="brand-wordmark">Hunter Premium Produce</span>
<span class="brand-subtitle">Operations workspace</span>
</a>
<span class="module-pill">{moduleCount} modules</span>
</div>
<div class="sidebar-body">
<AppNavSection
label="Modules"
ariaLabel="Client navigation"
items={primaryItems.map((item) => ({
label: item.label,
href: item.href,
icon: item.icon,
badge: item.badge,
active: matchesRoute(item.href, currentPath)
}))}
/>
<div class="rail-scroll">
<div class="rail-section-head">
<p class="rail-section-label">Modules</p>
<span class="rail-section-count">{moduleCount}</span>
</div>
{#if workingDocumentItems.length}
<AppNavSection
label="Working Docs"
ariaLabel="Working document pages"
items={workingDocumentItems.map((item) => ({
label: item.label,
href: item.href,
icon: item.icon,
active: matchesRoute(item.href, currentPath)
}))}
/>
{/if}
<nav class="rail-nav" aria-label="Workspace navigation">
{#each entries as entry}
{#if entry.kind === 'item'}
{@render leafLink(entry.item, true)}
{:else}
{@const group = entry.group}
{@const GroupIcon = group.icon}
{@const groupActive = groupHasActiveChild(group, currentPath)}
{@const open = isOpen(group.id)}
<div class="rail-group">
<button
type="button"
class="rail-row rail-group-toggle"
class:within-active={groupActive && !open}
aria-expanded={open}
onclick={() => toggleGroup(group.id)}
>
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
<span class="rail-text">{group.label}</span>
<span class="rail-group-meta">
<span class="rail-group-count">{group.children.length}</span>
<span class="rail-chevron" class:open aria-hidden="true">
<ChevronDown size={15} strokeWidth={2} />
</span>
</span>
</button>
{#if footerItems.length}
<AppNavSection
label="More"
ariaLabel="Workspace shortcuts"
items={footerItems.map((item) => ({
label: item.label,
href: item.href,
icon: item.icon
}))}
/>
{#if open}
<div class="rail-children">
{#each group.children as child}
{@render leafLink(child, false)}
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</nav>
</div>
<div class="sidebar-meta">
<AppNavSection
ariaLabel="Account actions"
items={[
...(canOpenSettings
? [
{
label: 'Settings',
icon: Settings,
active: currentPath.startsWith('/settings'),
onSelect: onOpenSettings,
type: 'button' as const
}
]
: []),
{
label: 'Logout',
icon: LogOut,
onSelect: onSignOut,
type: 'button' as const
}
]}
/>
<div class="rail-nav">
{#each footerItems as item}
{@render leafLink(item as NavItem, true)}
{/each}
{#if canOpenSettings}
{@render actionRow('Settings', Settings, currentPath.startsWith('/settings'), onOpenSettings)}
{/if}
{@render actionRow('Logout', LogOut, false, onSignOut)}
</div>
<div class="sidebar-meta-foot">
<div class="sidebar-meta-top">
@@ -119,18 +203,28 @@
</aside>
<style>
/* Light monochrome rail with a dark selected pill. The rail keeps its own palette
via the --sidebar-* tokens, independent of the content theme. */
.sidebar {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 1.1rem 0.85rem 0.85rem;
background: var(--panel);
border-right: 1px solid var(--line);
gap: 0.75rem;
padding: 1rem 0.8rem 0.85rem;
background: var(--sidebar-bg);
border-right: 1px solid var(--sidebar-border);
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
scrollbar-width: thin;
overflow: hidden;
}
.rail-section-label {
margin: 0;
color: var(--sidebar-text-muted);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.sidebar-body {
@@ -138,45 +232,301 @@
display: flex;
flex: 1;
flex-direction: column;
gap: 0.7rem;
gap: 0.75rem;
}
.rail-scroll {
min-height: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 0.55rem;
overflow-y: auto;
scrollbar-width: thin;
padding-right: 0.15rem;
}
.rail-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0 0.5rem;
}
.rail-section-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
height: 1.35rem;
padding: 0 0.42rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
color: var(--sidebar-text-muted);
font-size: 0.7rem;
font-weight: 700;
}
.brand-row {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 0.68rem;
padding: 0.2rem 0.35rem 0.95rem;
border-bottom: 1px solid var(--line);
gap: 0.9rem;
padding: 0.15rem 0.35rem 0.95rem;
border-bottom: 1px solid var(--sidebar-border);
}
.brand {
display: block;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.16rem;
min-width: 0;
padding: 0.08rem 0 0.1rem;
}
.brand-kicker {
color: var(--sidebar-text-muted);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.brand-wordmark {
font-size: 1rem;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--sidebar-text-strong);
}
.brand-subtitle {
color: var(--sidebar-text-muted);
font-size: 0.8rem;
line-height: 1.3;
}
.module-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.34rem 0.58rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-active-bg) 10%, transparent);
color: var(--sidebar-active-bg);
font-size: 0.7rem;
font-weight: 700;
white-space: nowrap;
}
/* ── Navigation rows ─────────────────────────────────────────── */
.rail-nav {
display: grid;
gap: 0.18rem;
}
.rail-row {
position: relative;
display: flex;
align-items: center;
gap: 0.72rem;
width: 100%;
min-height: 2.8rem;
padding: 0.62rem 0.72rem;
border: none;
border-radius: 0.9rem;
background: transparent;
color: var(--sidebar-text);
font-size: 0.92rem;
font-weight: 500;
letter-spacing: -0.01em;
text-align: left;
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease;
}
.sidebar-logo {
width: min(100%, 15.5rem);
max-width: none;
height: auto;
display: block;
object-fit: contain;
.rail-row:hover {
background: var(--sidebar-hover);
color: var(--sidebar-text-strong);
}
.rail-row:hover .rail-icon {
color: var(--sidebar-text-strong);
}
.rail-row.active,
.rail-row.active:hover {
background: var(--sidebar-active-bg);
color: var(--sidebar-active-text);
font-weight: 600;
}
.rail-row.active .rail-text,
.rail-row.active:hover .rail-text {
color: var(--sidebar-active-text);
}
.rail-row.active .rail-icon {
color: var(--sidebar-active-text);
}
.rail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
color: var(--sidebar-icon);
transition: color 140ms ease;
}
.rail-text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-badge {
margin-left: auto;
flex-shrink: 0;
padding: 0.12rem 0.42rem;
border-radius: 999px;
border: 1px solid var(--sidebar-border);
background: transparent;
color: var(--sidebar-text-muted);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
line-height: 1.5;
}
.rail-row.active .rail-badge {
border-color: color-mix(in srgb, var(--sidebar-active-text) 26%, transparent);
color: var(--sidebar-active-text);
background: color-mix(in srgb, var(--sidebar-active-text) 12%, transparent);
}
.rail-group {
display: grid;
gap: 0.2rem;
}
.rail-group-meta {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.42rem;
}
.rail-group-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.2rem;
padding: 0 0.32rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
color: var(--sidebar-text-muted);
font-size: 0.66rem;
font-weight: 700;
line-height: 1;
}
.rail-group-toggle.within-active {
color: var(--sidebar-text-strong);
font-weight: 600;
}
.rail-group-toggle.within-active .rail-group-count {
color: var(--sidebar-text-strong);
}
.rail-group-toggle.within-active .rail-icon,
.rail-group-toggle.within-active .rail-chevron {
color: var(--sidebar-text-strong);
}
.rail-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--sidebar-icon);
transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.rail-chevron.open {
transform: rotate(180deg);
}
.rail-children {
position: relative;
display: grid;
gap: 0.18rem;
margin: 0 0 0.15rem;
padding-left: 1.2rem;
animation: rail-reveal 170ms cubic-bezier(0.22, 1, 0.36, 1);
}
.rail-children::before {
content: '';
position: absolute;
top: 0.34rem;
bottom: 0.34rem;
left: 0.58rem;
width: 1px;
background: var(--sidebar-border);
}
.rail-children .rail-row {
min-height: 2.45rem;
padding: 0.48rem 0.62rem 0.48rem 0.82rem;
font-size: 0.88rem;
border-radius: 0.8rem;
}
@keyframes rail-reveal {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.rail-chevron,
.rail-children {
transition: none;
animation: none;
}
}
/* ── Footer / meta ───────────────────────────────────────────── */
.sidebar-meta {
margin-top: auto;
display: grid;
gap: 0.7rem;
padding-top: 1rem;
gap: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--sidebar-border);
flex-shrink: 0;
}
.sidebar-meta-foot {
display: grid;
gap: 0.55rem;
padding: 0.8rem 0.55rem 0;
border-top: 1px solid var(--line);
color: var(--muted);
padding: 0.7rem 0.5rem 0;
border-top: 1px solid var(--sidebar-border);
color: var(--sidebar-text-muted);
font-size: 0.76rem;
}
@@ -197,7 +547,7 @@
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--muted);
color: var(--sidebar-text-muted);
white-space: nowrap;
}
@@ -209,7 +559,7 @@
.powered-by strong {
font-size: 0.76rem;
font-weight: 600;
color: #5e6c64;
color: var(--sidebar-text);
}
.lean101-logo {
@@ -225,19 +575,18 @@
align-items: center;
gap: 0.45rem;
padding: 0.24rem 0.56rem;
border: 1px solid var(--line);
border: 1px solid var(--sidebar-border);
border-radius: 999px;
background: var(--panel-soft);
color: #5e6c64;
background: color-mix(in srgb, var(--sidebar-text-strong) 4%, transparent);
color: var(--sidebar-text);
font-size: 0.72rem;
font-weight: 600;
}
.meta-label {
color: var(--muted);
color: var(--sidebar-text-muted);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
</style>
@@ -1,6 +1,7 @@
<script lang="ts">
import { Settings } from 'lucide-svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
import type { AppSession } from '$lib/session';
import type { Crumb } from '$lib/navigation/client-navigation';
@@ -36,6 +37,9 @@
<header class="topbar">
<div class="topbar-start">
<a class="topbar-brand" href="/" aria-label="Hunter Premium Produce home">
<img src="/logo-hsf.png" alt="Hunter Premium Produce" />
</a>
<div class="topbar-copy">
<nav class="breadcrumbs" aria-label="Breadcrumb">
{#each breadcrumbs as crumb, index}
@@ -60,6 +64,8 @@
{/if}
<div class="topbar-actions">
<ThemeToggle />
<div class="menu-wrap user-menu-wrap">
<button aria-expanded={userMenuOpen} class="user-trigger" type="button" onclick={onToggleUserMenu}>
<span class="user-avatar-wrap">
@@ -127,8 +133,22 @@
.topbar-start {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 0.82rem;
align-items: center;
gap: 0.9rem;
}
.topbar-brand {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding-right: 0.9rem;
border-right: 1px solid var(--color-border);
}
.topbar-brand img {
height: 2rem;
width: auto;
display: block;
}
.topbar-copy h1 {
@@ -163,7 +183,7 @@
}
.breadcrumb-sep {
color: #b9c5be;
color: var(--color-text-muted);
font-size: 0.78rem;
}
@@ -176,7 +196,7 @@
:global(.topbar-search) {
width: 100%;
min-height: 2.75rem;
background: color-mix(in srgb, var(--panel-soft) 68%, white);
background: color-mix(in srgb, var(--panel-soft) 60%, var(--color-bg-surface));
}
.topbar-actions {
@@ -205,10 +225,10 @@
align-items: center;
gap: 0.72rem;
padding: 0.56rem 0.76rem;
border: 1px solid var(--line);
border: 1px solid var(--color-border);
border-radius: 0.96rem;
background: var(--panel-soft);
color: #304038;
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
}
@@ -225,8 +245,8 @@
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
background: var(--color-brand);
color: var(--color-on-brand);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
@@ -240,16 +260,16 @@
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
border: 1.5px solid var(--panel-soft);
background: #b4c0ba;
border: 1.5px solid var(--color-bg-surface);
background: var(--color-text-muted);
}
.user-status-dot.live {
background: #4ade80;
background: var(--color-success);
}
.user-status-dot.idle {
background: #c08b3d;
background: var(--color-warning);
}
.user-menu-avatar {
@@ -260,8 +280,8 @@
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
background: var(--color-brand);
color: var(--color-on-brand);
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.02em;
@@ -329,8 +349,8 @@
.chevron {
width: 0.54rem;
height: 0.54rem;
border-right: 2px solid #7a8c82;
border-bottom: 2px solid #7a8c82;
border-right: 2px solid var(--color-text-muted);
border-bottom: 2px solid var(--color-text-muted);
transform: rotate(45deg);
transition: transform 140ms ease;
}
@@ -350,16 +370,15 @@
padding: 0.4rem;
border: 1px solid var(--color-border);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
backdrop-filter: blur(10px);
background: var(--color-bg-elevated);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.menu-panel a,
.menu-panel button {
padding: 0.72rem 0.78rem;
border-radius: 0.78rem;
color: #304038;
color: var(--color-text-primary);
text-align: left;
background: transparent;
border: none;
@@ -397,6 +416,14 @@
padding: 0.72rem 1rem;
}
.topbar-brand {
padding-right: 0.6rem;
}
.topbar-brand img {
height: 1.6rem;
}
.user-trigger {
min-width: auto;
width: 100%;
@@ -35,18 +35,18 @@
}
.search-box:hover {
border-color: color-mix(in srgb, var(--color-brand) 22%, var(--line));
background: #fff;
border-color: color-mix(in srgb, var(--color-brand) 24%, var(--line));
background: var(--color-bg-surface);
}
.search-box:focus-visible {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 16%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
.search-placeholder {
color: #93a098;
color: var(--color-text-muted);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -58,7 +58,7 @@
display: inline-block;
width: 0.82rem;
height: 0.82rem;
border: 2px solid #98a59d;
border: 2px solid var(--color-text-muted);
border-radius: 999px;
}
@@ -70,7 +70,7 @@
width: 0.42rem;
height: 2px;
border-radius: 999px;
background: #98a59d;
background: var(--color-text-muted);
transform: rotate(45deg);
}
@@ -79,7 +79,7 @@
border: 1px solid var(--line-strong);
border-radius: 0.42rem;
color: var(--muted);
background: #fff;
background: var(--color-bg-surface);
font-size: 0.76rem;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { formatDate, formatLocaleNumber, formatNumber, toNum } from './format';
describe('format utilities', () => {
it('coerces numeric input safely', () => {
expect(toNum(' 12.5 ')).toBe(12.5);
expect(toNum(3)).toBe(3);
expect(toNum('')).toBeNull();
expect(toNum(Number.NaN)).toBeNull();
expect(toNum('not a number')).toBeNull();
});
it('formats fixed and locale numbers', () => {
expect(formatNumber(12.345, 2)).toBe('12.35');
expect(formatNumber(null, 2)).toBe('N/A');
expect(formatLocaleNumber(1234.56, 1, 'en-AU')).toBe('1,234.6');
expect(formatLocaleNumber(undefined, 1)).toBe('-');
});
it('formats dates with a fallback for empty values', () => {
expect(formatDate(null)).toBe('-');
expect(formatDate('not-a-date')).toBe('not-a-date');
expect(formatDate('2026-06-04', { day: '2-digit', month: 'short', year: 'numeric' }, 'en-AU')).toContain('2026');
});
});
+39
View File
@@ -0,0 +1,39 @@
export function toNum(value: unknown): number | null {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
const trimmed = String(value).trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
export function formatNumber(value: number | null | undefined, digits = 2, fallback = 'N/A') {
if (value === null || value === undefined) return fallback;
return value.toFixed(digits);
}
export function formatLocaleNumber(
value: number | null | undefined,
digits = 0,
locale = 'en-AU',
fallback = '-'
) {
if (value === null || value === undefined) return fallback;
return value.toLocaleString(locale, { maximumFractionDigits: digits });
}
export function formatDate(
value: string | null | undefined,
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium' },
locale = 'en-NZ',
fallback = '-'
) {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(locale, options).format(date);
}
@@ -1,7 +1,9 @@
import {
BadgeDollarSign,
Calculator,
ClipboardPenLine,
Gauge,
Layers,
LayoutDashboard,
ShieldCheck,
TrendingUp
@@ -37,6 +39,23 @@ export type FooterLink = {
icon: ComponentType;
};
/**
* A collapsible family of related modules in the primary rail. Groups keep the
* top level short as more modules ship: a new costing tool becomes another child
* here rather than another peer in a flat stack.
*/
export type NavGroup = {
id: string;
label: string;
icon: ComponentType;
children: NavItem[];
};
/** The rail is a sequence of standalone items and collapsible groups. */
export type NavEntry =
| { kind: 'item'; item: NavItem }
| { kind: 'group'; group: NavGroup };
export type Crumb = {
label: string;
href?: string;
@@ -58,9 +77,18 @@ export const mixCalculatorItem: NavItem = {
moduleKey: 'mix_calculator'
};
export const productCostingItem: NavItem = {
href: '/product-costing',
label: 'Product Costing',
shortLabel: 'PC',
icon: BadgeDollarSign,
moduleKey: 'products',
badge: 'Alpha'
};
export const editorItem: NavItem = {
href: '/editor',
label: 'Editor',
label: 'Mix Editor',
shortLabel: 'ED',
icon: ClipboardPenLine,
moduleKey: 'products',
@@ -100,6 +128,7 @@ export const accessControlItem: NavItem = {
export const clientNavigationItems: NavItem[] = [
dashboardItem,
mixCalculatorItem,
productCostingItem,
throughputItem,
editorItem,
accessControlItem
@@ -108,9 +137,15 @@ export const clientNavigationItems: NavItem[] = [
export const footerLinks: FooterLink[] = [];
export const baseSearchItems: SearchItem[] = [
{
href: '/product-costing',
label: 'Open Product Costing',
description: 'Maintain product costing records, assumptions, and calculated pricing.',
keywords: 'alpha product costing pricing finished delivered distributor wholesale margin spreadsheet'
},
{
href: '/editor',
label: 'Open Editor',
label: 'Open Mix Editor',
description: 'Edit client, product, and mix naming from one table.',
keywords: 'editor products mixes clients names bulk table phf horse manning'
},
@@ -162,6 +197,50 @@ export const baseSearchItems: SearchItem[] = [
},
];
/**
* Assemble the grouped primary rail from already access-filtered items.
* Callers pass only the modules the current session may see; empty families
* collapse away so a role with one costing tool never gets an empty group.
*
* Workflow-family layout: Dashboard, then a "Costing" group (the calculator,
* costing, editor, and master tools), then Operations and Insights modules at
* the top level until each grows into a family of its own.
*/
export function buildClientNavEntries(visible: {
dashboard?: NavItem | null;
costing: NavItem[];
throughput?: NavItem | null;
reporting?: NavItem | null;
}): NavEntry[] {
const entries: NavEntry[] = [];
if (visible.dashboard) {
entries.push({ kind: 'item', item: visible.dashboard });
}
if (visible.costing.length) {
entries.push({
kind: 'group',
group: { id: 'costing', label: 'Costing', icon: Layers, children: visible.costing }
});
}
if (visible.throughput) {
entries.push({ kind: 'item', item: visible.throughput });
}
if (visible.reporting) {
entries.push({ kind: 'item', item: visible.reporting });
}
return entries;
}
/** True when any of a group's children matches the current route. */
export function groupHasActiveChild(group: NavGroup, pathname: string) {
return group.children.some((child) => matchesRoute(child.href, pathname));
}
export function matchesRoute(href: string, pathname: string) {
return href === '/' ? pathname === '/' : pathname.startsWith(href);
}
@@ -185,8 +264,12 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
return [...crumbs, { label: 'Mix Calculator' }];
}
if (pathname.startsWith('/product-costing')) {
return [...crumbs, { label: 'Product Costing' }];
}
if (pathname.startsWith('/editor')) {
return [...crumbs, { label: 'Editor' }];
return [...crumbs, { label: 'Mix Editor' }];
}
if (pathname.startsWith('/mixes')) {
@@ -202,6 +285,7 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
const sectionMap: Record<string, string> = {
'/raw-materials': 'Raw Materials',
'/product-costing': 'Product Costing',
'/products': 'Products',
'/scenarios': 'Scenarios',
'/client-access': 'Client Access',
+541
View File
@@ -0,0 +1,541 @@
/* ============================================================================
Theme + design tokens (single source of truth)
----------------------------------------------------------------------------
Light is the default. Dark is opted into via <html data-theme="dark">, set
before first paint by the inline script in app.html and kept in sync by
$lib/theme.ts. Every colour the app uses resolves from a token here, so a
surface that reads from tokens themes automatically in both modes.
Scene that forced the dark palette: an operations lead reconciling pasta
production costs late in a dim back-office, wanting the glare off a white
screen without losing the soft-green brand identity.
============================================================================ */
:root {
color-scheme: light;
/* Brand: green-forward (Weavy). Emerald carries primary actions,
active content states, charts, and positive deltas. */
--color-brand: oklch(0.56 0.125 162); /* primary green button */
--color-brand-hover: oklch(0.49 0.115 162);
--color-brand-tint: oklch(0.95 0.04 162);
--color-on-brand: oklch(0.99 0.012 162);
--color-secondary: oklch(0.45 0.01 240); /* neutral secondary */
/* ── Accent (brighter emerald for data / positive emphasis) ── */
--color-accent: oklch(0.65 0.15 162);
--color-accent-hover: oklch(0.56 0.13 162);
--color-accent-tint: oklch(0.95 0.05 162);
/* ── Content surfaces: neutral light-gray canvas, white cards ── */
--color-bg-app: oklch(0.966 0.002 240);
--color-bg-surface: oklch(0.998 0.001 240);
--color-bg-elevated: oklch(0.99 0.0015 240);
--color-surface-hover: oklch(0.955 0.004 240);
--color-surface-selected: color-mix(in srgb, var(--color-brand) 10%, var(--color-bg-surface));
/* ── Borders ────────────────────────────────────────────── */
--color-border: oklch(0.92 0.005 240);
--color-divider: oklch(0.94 0.004 240);
/* ── Text (neutral) ─────────────────────────────────────── */
--color-text-primary: oklch(0.25 0.006 240);
--color-text-secondary: oklch(0.45 0.008 240);
--color-text-muted: oklch(0.6 0.01 240);
/* Sidebar: light monochrome rail with the current item shown as the
selected pill. Shared across themes so navigation stays consistent. */
--sidebar-bg: oklch(0.985 0.001 240);
--sidebar-hover: oklch(0.952 0.003 240);
--sidebar-active-bg: #3290d9;
--sidebar-active-text: var(--color-on-brand);
--sidebar-border: oklch(0.9 0.004 240);
--sidebar-text: oklch(0.34 0.006 240);
--sidebar-text-strong: oklch(0.16 0.004 240);
--sidebar-text-muted: oklch(0.56 0.008 240);
--sidebar-icon: oklch(0.42 0.006 240);
--sidebar-logo-bg: oklch(0.98 0.003 240);
/* ── Semantic ───────────────────────────────────────────── */
--color-success: oklch(0.66 0.16 162); /* emerald, cohesive with accent */
--color-warning: oklch(0.66 0.12 78);
--color-error: oklch(0.58 0.2 25);
--color-info: oklch(0.55 0.13 245);
--color-success-text: oklch(0.52 0.14 162);
--color-success-tint: oklch(0.95 0.05 164);
--color-warning-text: oklch(0.45 0.11 69);
--color-warning-tint: oklch(0.96 0.045 78);
--color-info-tint: oklch(0.965 0.025 245);
/* Brand-literal deep green: kept for legacy success / positive
text and tints. Solid brand chips point at --color-brand. */
--green-deep: oklch(0.42 0.13 150);
/* ── Radii / spacing (theme-independent) ────────────────── */
--radius-panel: 0.9rem;
--radius-control: 0.68rem;
--radius-row: 0.78rem;
--space-page: 1.25rem;
--space-card: 1.15rem;
--shadow: none; /* flat by design: separate with borders, not shadows */
/* Legacy aliases (resolve through the tokens above, so they
re-theme automatically when the tokens are overridden).
--green maps to the accent so existing green usages stay green. */
--bg: var(--color-bg-app);
--panel: var(--color-bg-surface);
--panel-soft: var(--color-bg-app);
--line: var(--color-border);
--line-strong: var(--color-border);
--text: var(--color-text-primary);
--muted: var(--color-text-muted);
--green: var(--color-accent);
--green-soft: var(--color-success-tint);
--blue-soft: var(--color-info-tint);
}
:root[data-theme='dark'] {
color-scheme: dark;
/* ── Brand: green-forward, brightened for dark ─────────────── */
--color-brand: oklch(0.62 0.13 162);
--color-brand-hover: oklch(0.7 0.13 162);
--color-brand-tint: oklch(0.32 0.06 162);
--color-on-brand: oklch(0.99 0.012 162);
--color-secondary: oklch(0.72 0.01 240);
/* ── Accent (emerald) ───────────────────────────────────── */
--color-accent: oklch(0.72 0.15 162);
--color-accent-hover: oklch(0.8 0.14 162);
--color-accent-tint: oklch(0.33 0.06 162);
/* ── Surfaces (neutral dark) ────────────────────────────── */
--color-bg-app: oklch(0.17 0.004 240);
--color-bg-surface: oklch(0.215 0.005 240);
--color-bg-elevated: oklch(0.26 0.006 240);
--color-surface-hover: oklch(0.27 0.006 240);
--color-surface-selected: color-mix(in srgb, var(--color-brand) 14%, var(--color-bg-surface));
/* ── Borders ────────────────────────────────────────────── */
--color-border: oklch(0.32 0.006 240);
--color-divider: oklch(0.28 0.005 240);
/* ── Text (neutral) ─────────────────────────────────────── */
--color-text-primary: oklch(0.96 0.003 240);
--color-text-secondary: oklch(0.78 0.006 240);
--color-text-muted: oklch(0.62 0.008 240);
/* ── Semantic ───────────────────────────────────────────── */
--color-success: oklch(0.75 0.16 162);
--color-warning: oklch(0.8 0.12 80);
--color-error: oklch(0.7 0.18 25);
--color-info: oklch(0.72 0.12 240);
--color-success-text: oklch(0.84 0.14 162);
--color-success-tint: oklch(0.32 0.06 162);
--color-warning-text: oklch(0.85 0.1 80);
--color-warning-tint: oklch(0.32 0.05 78);
--color-info-tint: oklch(0.3 0.05 240);
--green-deep: oklch(0.7 0.15 150);
}
/* ============================================================================
Base
============================================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background: var(--color-bg-app);
color: var(--color-text-primary);
font-family: 'Inter', 'Segoe UI', sans-serif;
font-size: 14px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Inter', 'Segoe UI', sans-serif;
letter-spacing: -0.03em;
}
button,
input,
select,
textarea {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-brand) 42%, transparent);
outline-offset: 2px;
}
/* ============================================================================
Shared product surfaces (used across every route, so they live here rather
than in any one shell component)
============================================================================ */
.ui-stack {
display: grid;
gap: var(--space-page);
}
.ui-panel,
.ui-metric-card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-panel);
box-shadow: var(--shadow);
}
.ui-panel {
padding: var(--space-card);
}
.ui-panel-soft {
background: var(--panel-soft);
border: 1px solid var(--color-border);
border-radius: var(--radius-row);
}
.ui-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
margin-bottom: 1rem;
}
.ui-section-heading h3,
.ui-section-heading h4 {
margin: 0.18rem 0 0;
font-size: 1.06rem;
font-weight: 700;
letter-spacing: 0;
}
.ui-eyebrow {
margin: 0;
color: var(--color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ui-muted {
color: var(--color-text-muted);
}
.ui-metric-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.ui-metric-card {
padding: 1.05rem 1.1rem;
}
.ui-metric-card span {
display: block;
color: var(--color-text-muted);
font-size: 0.84rem;
}
.ui-metric-card strong {
display: block;
margin: 0.5rem 0 0.28rem;
font-size: 1.75rem;
font-weight: 700;
}
.ui-metric-card p {
margin: 0;
color: var(--color-text-muted);
}
.ui-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.6rem;
padding: 0.72rem 0.9rem;
border-radius: var(--radius-control);
font-weight: 600;
cursor: pointer;
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), color 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ui-button.primary {
border: 1px solid var(--color-brand);
color: var(--color-on-brand);
background: var(--color-brand);
}
.ui-button.primary:hover:not(:disabled) {
background: var(--color-brand-hover);
border-color: var(--color-brand-hover);
}
.ui-button.secondary {
border: 1px solid var(--color-border);
color: var(--color-text-primary);
background: var(--color-bg-surface);
}
.ui-button.secondary:hover:not(:disabled) {
background: var(--color-surface-hover);
}
.ui-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ui-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
border-radius: 999px;
padding: 0.4rem 0.74rem;
font-size: 0.82rem;
font-weight: 600;
text-transform: capitalize;
white-space: nowrap;
}
.ui-pill.positive {
color: var(--color-success-text);
background: var(--color-success-tint);
}
.ui-pill.warning {
color: var(--color-warning-text);
background: var(--color-warning-tint);
}
.ui-pill.neutral {
color: var(--color-text-secondary);
background: color-mix(in srgb, var(--panel-soft) 74%, var(--color-bg-surface));
}
.ui-table-wrap {
overflow-x: auto;
}
.ui-table {
width: 100%;
min-width: 48rem;
border-collapse: separate;
border-spacing: 0 0.65rem;
}
.ui-table th,
.ui-table td {
padding: 0.9rem 0.95rem;
text-align: left;
white-space: nowrap;
}
.ui-table th {
color: var(--color-text-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ui-table tbody td {
background: var(--panel-soft);
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
}
.ui-table tbody td:first-child {
border-left: 1px solid var(--color-border);
border-radius: var(--radius-row) 0 0 var(--radius-row);
}
.ui-table tbody td:last-child {
border-right: 1px solid var(--color-border);
border-radius: 0 var(--radius-row) var(--radius-row) 0;
}
.ui-table-identity {
display: flex;
align-items: center;
gap: 0.74rem;
min-width: 0;
}
.ui-row-mark {
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.76rem;
color: var(--color-on-brand);
background: var(--color-brand);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
}
.ui-table-identity strong,
.ui-number-block strong {
display: block;
font-size: 0.94rem;
}
.ui-table-identity span,
.ui-number-block span {
display: block;
margin-top: 0.16rem;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.ui-number-block {
display: grid;
gap: 0.08rem;
}
.ui-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.ui-form-grid.compact {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.ui-field {
display: grid;
gap: 0.36rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
font-weight: 600;
}
.ui-field input,
.ui-field textarea,
.ui-field select {
width: 100%;
padding: 0.82rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--panel-soft);
color: var(--color-text-primary);
transition: background-color 160ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.ui-field input:focus,
.ui-field textarea:focus,
.ui-field select:focus {
outline: none;
border-color: var(--color-brand);
background: var(--color-bg-surface);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
}
@media (max-width: 980px) {
.ui-metric-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.ui-section-heading {
flex-direction: column;
align-items: flex-start;
}
.ui-table {
min-width: 0;
border-spacing: 0;
}
.ui-table,
.ui-table thead,
.ui-table tbody,
.ui-table tr,
.ui-table td {
display: block;
width: 100%;
}
.ui-table thead {
display: none;
}
.ui-table tbody {
display: grid;
gap: 0.85rem;
}
.ui-table tbody tr {
padding: 0.3rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-row);
background: var(--panel-soft);
}
.ui-table tbody td {
padding: 0.76rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
.ui-table tbody td:first-child,
.ui-table tbody td:last-child {
border: none;
border-radius: 0;
}
.ui-table tbody td + td {
border-top: 1px solid var(--color-border);
}
.ui-table tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ui-form-grid,
.ui-form-grid.compact {
grid-template-columns: 1fr;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export type ThemePreference = 'light' | 'dark' | 'system';
export type ResolvedTheme = 'light' | 'dark';
const STORAGE_KEY = 'theme';
function systemTheme(): ResolvedTheme {
return browser && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function resolve(pref: ThemePreference): ResolvedTheme {
return pref === 'system' ? systemTheme() : pref;
}
function readPreference(): ThemePreference {
if (!browser) return 'system';
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
}
function applyResolved(theme: ResolvedTheme) {
if (browser) {
document.documentElement.dataset.theme = theme;
}
}
/** The user's stored choice (may be 'system'). */
export const themePreference = writable<ThemePreference>(readPreference());
/** The theme actually painted right now ('system' collapsed to light/dark). */
export const resolvedTheme = writable<ResolvedTheme>(resolve(readPreference()));
if (browser) {
themePreference.subscribe((pref) => {
window.localStorage.setItem(STORAGE_KEY, pref);
const next = resolve(pref);
resolvedTheme.set(next);
applyResolved(next);
});
// Follow the OS only while the user is on 'system'.
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (readPreference() === 'system') {
const next = systemTheme();
resolvedTheme.set(next);
applyResolved(next);
}
});
}
/** Flip between light and dark, committing to an explicit preference. */
export function toggleTheme() {
themePreference.update((pref) => (resolve(pref) === 'dark' ? 'light' : 'dark'));
}
+100
View File
@@ -208,6 +208,73 @@ export type ProductCostBreakdown = {
inputs?: Record<string, unknown>;
};
export type ProductCostingItem = {
id: number;
tenant_id: string;
client_category: string;
item_id: string | null;
product_name: string;
mix_product_name: string;
unit_type: string;
own_bag: string | null;
unit_kg: number | null;
items_per_pallet: number | null;
bagging_process: string | null;
manual_distributor_margin: number | null;
manual_wholesale_margin: number | null;
cleaned_product_cost_per_kg: number | null;
grading_cost_per_kg: number | null;
bagging_cost_per_kg: number | null;
cracking_cost_per_kg: number | null;
bag_cost_per_unit: number | null;
freight_cost_per_unit: number | null;
finished_product_delivered_cost: number | null;
distributor_price: number | null;
wholesale_price: number | null;
warnings: string[];
created_at: string;
updated_at: string;
};
export type ProductCostingItemUpdateInput = {
client_category?: string;
item_id?: string | null;
product_name?: string;
mix_product_name?: string;
unit_type?: string;
own_bag?: string | null;
unit_kg?: number | null;
items_per_pallet?: number | null;
bagging_process?: string | null;
manual_distributor_margin?: number | null;
manual_wholesale_margin?: number | null;
};
export type ProductCostingNamedInput = {
key: string;
label: string;
cost: number;
};
export type ProductCostingClientInput = {
client_category: string;
distributor_margin: number | null;
wholesale_margin: number | null;
};
export type ProductCostingInputs = {
base: {
grading_per_tonne: number;
grading_per_kg: number;
cracking_per_tonne: number;
cracking_per_kg: number;
};
processes: ProductCostingNamedInput[];
clients: ProductCostingClientInput[];
bags: ProductCostingNamedInput[];
freight: ProductCostingNamedInput[];
};
export type EditorProductRow = {
id: number;
tenant_id: string;
@@ -382,6 +449,39 @@ export type DashboardSummary = {
mix_cost_per_kg: number[];
product_finished_delivered: number[];
};
operations?: {
period_label: string;
total_kg: number;
total_bags: number;
entry_count: number;
estimated_wholesale_value: number;
priced_entry_count: number;
top_products: Array<{
product_name: string;
client_name: string | null;
kg: number;
bags: number;
entries: number;
}>;
client_totals: Array<{
client_name: string;
kg: number;
}>;
pricing_issues: {
missing_lookup: number;
missing_unit_kg: number;
missing_pallet_qty: number;
missing_price: number;
invalid_margin: number;
total: number;
};
produced_not_priced: Array<{
product_name: string;
kg: number;
status: string;
warnings: string[];
}>;
} | null;
};
export type LoginResponse = {
+10
View File
@@ -89,6 +89,10 @@ export function canOpenProducts(session: AppSession | null | undefined) {
return canAccessWorkspaceArea(session, 'products', ['view_products', 'edit_products']);
}
export function canOpenProductCosting(session: AppSession | null | undefined) {
return canOpenProducts(session);
}
export function canOpenEditor(session: AppSession | null | undefined) {
if (!session) {
return false;
@@ -140,6 +144,11 @@ export const routeAccessRules: RouteAccessRule[] = [
matches: (pathname) => hasPathPrefix(pathname, '/raw-materials')
},
{ path: '/mixes', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/mixes') },
{
path: '/product-costing',
roles: ['admin', 'full', 'client'],
matches: (pathname) => hasPathPrefix(pathname, '/product-costing')
},
{ path: '/products', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/products') },
{ path: '/editor', roles: ['admin'], matches: (pathname) => hasPathPrefix(pathname, '/editor') },
{ path: '/reporting', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/reporting') },
@@ -196,6 +205,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
if (pathname.startsWith('/mix-calculator')) return canOpenMixCalculator(session);
if (pathname.startsWith('/raw-materials')) return canOpenRawMaterials(session);
if (pathname.startsWith('/mixes')) return canOpenMixMaster(session);
if (pathname.startsWith('/product-costing')) return canOpenProductCosting(session);
if (pathname.startsWith('/products')) return canOpenProducts(session);
if (pathname.startsWith('/editor')) return canOpenEditor(session);
if (pathname.startsWith('/scenarios')) return canOpenScenarios(session);
+2 -16
View File
@@ -3,6 +3,8 @@
import '@fontsource/inter/latin-500.css';
import '@fontsource/inter/latin-600.css';
import '@fontsource/inter/latin-700.css';
import '$lib/styles/theme.css';
import '$lib/theme';
import { beforeNavigate, afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import AdminShell from '$lib/components/AdminShell.svelte';
@@ -51,19 +53,3 @@
<Toast />
<style>
:global(html, body) {
font-family: "Inter", "Segoe UI", sans-serif;
}
:global(button),
:global(input),
:global(select),
:global(textarea) {
font: inherit;
}
:global(h1, h2, h3, h4, h5, h6) {
font-family: "Inter", "Segoe UI", sans-serif;
}
</style>
+471 -17
View File
@@ -6,7 +6,18 @@
import type { DashboardSummary } from '$lib/types';
import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access';
import packageInfo from '../../package.json';
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
import {
ArrowUpRight,
BadgeDollarSign,
Factory,
PackageCheck,
Scale,
Sun,
Sunrise,
Sunset,
TriangleAlert,
Moon
} from 'lucide-svelte';
import { tick } from 'svelte';
type Segment = {
@@ -115,6 +126,13 @@
return `$${value.toFixed(digits)}`;
}
function kg(value: number | null | undefined) {
if (value === null || value === undefined) {
return '0 kg';
}
return `${value.toLocaleString(undefined, { maximumFractionDigits: 0 })} kg`;
}
function formatDate(value: string | null | undefined) {
if (!value) {
return 'No date';
@@ -314,6 +332,7 @@
const trendArea = $derived(areaPath(trendSeries));
const trendFocus = $derived(focusMarker(trendSeries));
const topProducts = $derived(summary?.products?.top_products ?? []);
const operations = $derived(summary?.operations ?? null);
const focusCards = $derived(buildFocusCards(summary));
const loading = $derived(summary === null);
const greeting = $derived(timeOfDay());
@@ -465,7 +484,7 @@
</div>
<div class="intro-actions">
{#if canOpenEditor($clientSession)}
<a class="primary-button" href="/editor">Open Editor</a>
<a class="primary-button" href="/editor">Open Mix Editor</a>
{/if}
</div>
</section>
@@ -610,6 +629,146 @@
</div>
</section>
<section class="operations-report">
<div class="card-toolbar operations-toolbar">
<div class="operations-heading">
<span class="operations-icon"><Factory size={24} strokeWidth={2.2} /></span>
<div>
<h3>Production And Pricing</h3>
<p>Throughput and Product Costing for {operations?.period_label ?? 'this month'}</p>
</div>
</div>
<a class="secondary-button compact operations-link" href="/product-costing">
Open Product Costing
<ArrowUpRight size={15} strokeWidth={2.3} />
</a>
</div>
<div class="operations-graphic" aria-hidden="true">
<div class="operations-graphic-labels">
<span>Throughput</span>
<span>Costing</span>
<span>Pricing</span>
</div>
<div class="operations-graphic-track">
<span class="operation-node production-node"></span>
<span class="operation-track"></span>
<span class="operation-bars">
<i></i>
<i></i>
<i></i>
</span>
<span class="operation-node pricing-node"></span>
</div>
</div>
<div class="operations-metrics">
<article class="produced">
<div class="metric-label">
<span>Produced</span>
<span class="metric-symbol"><PackageCheck size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="6rem" height="1.5rem" /></strong>
{:else}
<strong>{kg(operations?.total_kg)}</strong>
{/if}
<p>{operations?.entry_count ?? 0} throughput entries</p>
</article>
<article class="bags">
<div class="metric-label">
<span>Bags</span>
<span class="metric-symbol"><Scale size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="5rem" height="1.5rem" /></strong>
{:else}
<strong>{(operations?.total_bags ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</strong>
{/if}
<p>Logged as bag runs</p>
</article>
<article class="value">
<div class="metric-label">
<span>Wholesale Value</span>
<span class="metric-symbol"><BadgeDollarSign size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="7rem" height="1.5rem" /></strong>
{:else}
<strong>{currency(operations?.estimated_wholesale_value)}</strong>
{/if}
<p>{operations?.priced_entry_count ?? 0} priced entries</p>
</article>
<article class:warning={(operations?.pricing_issues?.total ?? 0) > 0}>
<div class="metric-label">
<span>Pricing Issues</span>
<span class="metric-symbol"><TriangleAlert size={17} strokeWidth={2.2} /></span>
</div>
{#if loading}
<strong><Skeleton width="4rem" height="1.5rem" /></strong>
{:else}
<strong>{operations?.pricing_issues?.total ?? 0}</strong>
{/if}
<p>Products needing review</p>
</article>
</div>
<div class="operations-grid">
<article>
<div class="mini-heading">
<strong>Top Produced</strong>
<span>By kg</span>
</div>
<div class="report-list">
{#if loading}
{#each Array(4) as _}
<div><Skeleton width="10rem" /><Skeleton width="4rem" /></div>
{/each}
{:else if operations?.top_products?.length}
{#each operations.top_products as product}
<div>
<span>
<strong>{product.product_name}</strong>
<small>{product.client_name ?? 'No client'} · {product.entries} entries</small>
</span>
<em>{kg(product.kg)}</em>
</div>
{/each}
{:else}
<p>No throughput recorded this month.</p>
{/if}
</div>
</article>
<article>
<div class="mini-heading">
<strong>Produced But Not Priced</strong>
<span>Fix these first</span>
</div>
<div class="report-list">
{#if loading}
{#each Array(4) as _}
<div><Skeleton width="10rem" /><Skeleton width="4rem" /></div>
{/each}
{:else if operations?.produced_not_priced?.length}
{#each operations.produced_not_priced as product}
<div>
<span>
<strong>{product.product_name}</strong>
<small>{product.warnings[0] ?? product.status}</small>
</span>
<em>{kg(product.kg)}</em>
</div>
{/each}
{:else}
<p>All produced products have usable pricing.</p>
{/if}
</div>
</article>
</div>
</section>
<section class="analysis-grid">
<article class="panel-card chart-card">
<div class="card-toolbar">
@@ -1094,6 +1253,7 @@
.workspace-banner,
.focus-row,
.dashboard-grid,
.operations-report,
.analysis-grid,
.detail-grid {
margin-bottom: 1.25rem;
@@ -1177,15 +1337,15 @@
.primary-button {
border: none;
color: #fff;
color: var(--color-on-brand);
background: var(--color-brand);
box-shadow: none;
}
.secondary-button {
border: 1px solid var(--line-strong);
color: #304038;
background: #fff;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
background: var(--color-bg-surface);
}
.secondary-button.compact {
@@ -1206,6 +1366,276 @@
padding: 1.2rem;
}
.operations-report {
position: relative;
overflow: hidden;
padding: 1.2rem;
border: 1px solid var(--color-border);
border-radius: 1.4rem;
background: var(--color-bg-surface);
box-shadow: var(--shadow);
}
.operations-toolbar {
margin-bottom: 0.9rem;
}
.operations-heading {
display: flex;
align-items: center;
gap: 0.85rem;
min-width: 0;
}
.operations-heading h3 {
margin: 0 0 0.18rem;
color: var(--text);
}
.operations-heading p {
margin: 0;
color: var(--muted);
}
.operations-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.1rem;
height: 3.1rem;
flex-shrink: 0;
border: 1px solid rgba(21, 128, 61, 0.18);
border-radius: 1rem;
background: #f0f8f3;
color: #0f6f3d;
box-shadow: inset 0 -0.45rem 1rem rgba(21, 128, 61, 0.08);
}
.operations-graphic {
display: grid;
gap: 0.5rem;
margin-bottom: 0.95rem;
padding: 0.75rem 0.85rem;
border: 1px solid rgba(214, 228, 220, 0.86);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.62);
box-shadow: inset 0 -0.6rem 1.4rem rgba(21, 128, 61, 0.06);
}
.operations-graphic-labels,
.operations-graphic-track {
display: grid;
grid-template-columns: 2.25rem minmax(4rem, 1fr) 4.5rem 2.25rem;
align-items: center;
gap: 0.45rem;
}
.operations-graphic-labels {
grid-template-columns: repeat(3, minmax(0, 1fr));
color: #365243;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.operations-graphic-labels span:nth-child(2) {
text-align: center;
}
.operations-graphic-labels span:nth-child(3) {
text-align: right;
}
.operation-node {
display: block;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.8rem;
background: #0f6f3d;
box-shadow: inset 0 -0.35rem 0.65rem rgba(0, 0, 0, 0.12);
}
.pricing-node {
background: #e7ad3c;
}
.operation-track {
align-self: center;
height: 0.24rem;
border-radius: 999px;
background: linear-gradient(90deg, rgba(21, 128, 61, 0.22), rgba(59, 130, 196, 0.58), rgba(231, 173, 60, 0.55));
}
.operation-bars {
display: flex;
align-items: end;
justify-content: center;
gap: 0.28rem;
height: 2.4rem;
}
.operation-bars i {
display: block;
width: 0.7rem;
border-radius: 999px 999px 0.25rem 0.25rem;
background: #3b82c4;
}
.operation-bars i:nth-child(1) {
height: 1.2rem;
}
.operation-bars i:nth-child(2) {
height: 2rem;
background: #15803d;
}
.operation-bars i:nth-child(3) {
height: 1.55rem;
background: #e7ad3c;
}
.operations-link {
gap: 0.45rem;
white-space: nowrap;
}
.operations-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.operations-metrics article {
position: relative;
overflow: hidden;
padding: 0.95rem;
border: 1px solid rgba(214, 228, 220, 0.94);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.84);
box-shadow: 0 0.75rem 1.4rem rgba(43, 57, 47, 0.05);
}
.operations-metrics article::after {
content: '';
position: absolute;
right: 0.85rem;
bottom: 0;
left: 0.85rem;
height: 0.24rem;
border-radius: 999px 999px 0 0;
background: #15803d;
opacity: 0.65;
}
.operations-metrics article.bags::after {
background: #3b82c4;
}
.operations-metrics article.value::after {
background: #e7ad3c;
}
.operations-metrics article.warning {
border-color: rgba(231, 173, 60, 0.44);
background: #fff8e8;
}
.operations-metrics article.warning::after {
background: #b45309;
}
.metric-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.metric-symbol {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #0f6f3d;
}
.bags .metric-symbol {
color: #2f6f9f;
}
.value .metric-symbol {
color: #9a6718;
}
.warning .metric-symbol {
color: #9a3412;
}
.metric-label span,
.operations-metrics p,
.mini-heading span,
.report-list small {
color: var(--muted);
}
.operations-metrics strong {
display: block;
margin: 0.4rem 0 0.25rem;
font-size: 1.55rem;
line-height: 1;
}
.operations-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.operations-grid > article {
padding-top: 0.9rem;
border-top: 1px solid var(--line);
}
.mini-heading,
.report-list div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.mini-heading {
margin-bottom: 0.65rem;
}
.report-list {
display: grid;
gap: 0.55rem;
}
.report-list div {
padding: 0.65rem 0;
border-bottom: 1px solid var(--line);
}
.report-list div:last-child {
border-bottom: none;
}
.report-list strong,
.report-list small {
display: block;
}
.report-list em {
flex-shrink: 0;
font-style: normal;
font-weight: 700;
}
.focus-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1249,15 +1679,15 @@
}
.focus-card.positive {
background: linear-gradient(180deg, #f6fbf7 0%, #edf8f0 100%);
background: var(--color-success-tint);
}
.focus-card.warning {
background: linear-gradient(180deg, #fffaf3 0%, #fff3e3 100%);
background: var(--color-warning-tint);
}
.focus-card.neutral {
background: linear-gradient(180deg, #f7faf8 0%, #eff4f1 100%);
background: var(--panel-soft);
}
.focus-code {
@@ -1267,8 +1697,8 @@
align-items: center;
justify-content: center;
border-radius: 0.75rem;
color: #fff;
background: var(--green-deep);
color: var(--color-on-brand);
background: var(--color-brand);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.05em;
@@ -1369,8 +1799,9 @@
}
.toggle-pill .active {
color: #fff;
background: var(--green-deep);
color: var(--color-text-primary);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
}
.market-layout {
@@ -1410,7 +1841,11 @@
height: 8.5rem;
flex-shrink: 0;
border-radius: 1.4rem;
background: linear-gradient(180deg, #d7f0ff 0%, #fff0bf 45%, #89c762 46%, #3f8e3d 100%);
background: linear-gradient(
150deg,
var(--color-brand) 0%,
color-mix(in srgb, var(--color-brand) 70%, var(--color-text-primary)) 100%
);
overflow: hidden;
}
@@ -1421,7 +1856,12 @@
width: 2.9rem;
height: 2.9rem;
border-radius: 999px;
background: radial-gradient(circle at 35% 35%, #fff8cc 0%, #ffd865 58%, #f4ae1f 100%);
background: radial-gradient(
circle at 38% 38%,
color-mix(in srgb, var(--color-on-brand) 38%, transparent) 0%,
color-mix(in srgb, var(--color-on-brand) 12%, transparent) 62%,
transparent 72%
);
}
.field-stripe {
@@ -1430,7 +1870,7 @@
right: -10%;
height: 22%;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
background: color-mix(in srgb, var(--color-on-brand) 16%, transparent);
transform: rotate(-18deg);
}
@@ -1864,10 +2304,15 @@
@media (max-width: 1120px) {
.analysis-grid,
.detail-grid {
.detail-grid,
.operations-grid {
grid-template-columns: 1fr;
}
.operations-link {
justify-self: start;
}
.focus-grid {
min-width: 0;
}
@@ -1889,6 +2334,10 @@
.focus-grid {
grid-template-columns: 1fr;
}
.operations-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
@@ -1935,10 +2384,15 @@
}
.preview-facts,
.operations-metrics,
.signin-form {
grid-template-columns: 1fr;
}
.operations-graphic {
width: 100%;
}
.field-emblem {
width: 6.5rem;
height: 6.5rem;
+2 -1
View File
@@ -8,7 +8,8 @@ const EMPTY_SUMMARY: DashboardSummary = {
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] }
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] },
operations: null
};
// Streaming load: the route shell paints immediately and the dashboard fills
+161 -93
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import type { EditorProductFormula, EditorProductIngredient, EditorProductRow, RawMaterial } from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
@@ -345,49 +346,22 @@
});
</script>
<section class="editor">
<div class="status-band">
<div class="editor-status">
<span>
<strong>Editor</strong>
<small>{visibleRows.length} products across {uniqueMixCount} mixes</small>
</span>
<AppSecondaryRailLayout>
{#snippet rail()}
<div class="filter-rail" aria-label="Mix Editor filters">
<p class="rail-label">Mix Editor</p>
<div class="rail-identity">
<div class="rail-avatar" aria-hidden="true">
<ListFilter size={16} strokeWidth={1.8} />
</div>
<div class="rail-identity-text">
<p class="identity-name">Filter products</p>
<p class="identity-role">{visibleRows.length} matching rows</p>
</div>
</div>
<dl class="facts">
<div class="fact">
<dt>Products</dt>
<dd>{visibleRows.length}</dd>
</div>
<div class="fact">
<dt>Mixes</dt>
<dd>{uniqueMixCount}</dd>
</div>
<div class="fact">
<dt>Unsaved</dt>
<dd>{dirtyCount}</dd>
</div>
</dl>
</div>
<section class="filters" aria-label="Editor filters">
<div class="filter-head">
<div>
<span class="section-label">
<ListFilter size={15} strokeWidth={2.2} />
Filters
</span>
<strong>{visibleRows.length} matching products</strong>
</div>
{#if filtersActive}
<button type="button" class="clear-button" onclick={clearFilters}>
<X size={16} strokeWidth={2.4} /> Clear filters
</button>
{/if}
</div>
<div class="filter-grid">
<div class="filter-rail-body">
<label class="filter-search">
<span>Search</span>
<div class="search-input">
@@ -429,8 +403,40 @@
{/each}
</select>
</label>
{#if filtersActive}
<button type="button" class="clear-button rail-clear" onclick={clearFilters}>
<X size={16} strokeWidth={2.4} /> Clear filters
</button>
{/if}
</div>
</div>
{/snippet}
<section class="editor">
<div class="status-band">
<div class="editor-status">
<span>
<strong>Mix Editor</strong>
<small>{visibleRows.length} products across {uniqueMixCount} mixes</small>
</span>
</div>
<dl class="facts">
<div class="fact">
<dt>Products</dt>
<dd>{visibleRows.length}</dd>
</div>
<div class="fact">
<dt>Mixes</dt>
<dd>{uniqueMixCount}</dd>
</div>
<div class="fact">
<dt>Unsaved</dt>
<dd>{dirtyCount}</dd>
</div>
</dl>
</div>
</section>
<div class="pagination-bar" aria-label="Product table pagination">
<span>{pageStart}-{pageEnd} of {visibleRows.length}</span>
@@ -577,14 +583,103 @@
</div>
{/each}
</div>
</section>
</section>
</AppSecondaryRailLayout>
<style>
.editor {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0 2rem;
gap: 0.9rem;
min-height: 100%;
padding: 1rem 1.15rem 2rem;
background: #e8eee9;
}
:global(.secondary-rail-layout) {
margin-bottom: 1.25rem;
}
:global(.secondary-rail-layout-panel),
:global(.secondary-rail-layout-content) {
background: #e8eee9;
}
.filter-rail {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
height: 100%;
min-height: calc(100vh - 8.5rem);
background: color-mix(in srgb, var(--panel-soft) 46%, #dfe7e1);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.rail-label {
margin: 0;
padding: 1rem 1rem 0.15rem;
color: color-mix(in srgb, var(--muted) 88%, #a3aea7);
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.rail-identity {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 1rem 1.15rem;
border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
}
.rail-avatar {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.15rem;
height: 2.15rem;
border: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--panel) 80%, #edf2ee);
color: #6b786f;
}
.rail-identity-text {
min-width: 0;
}
.identity-name,
.identity-role {
margin: 0;
}
.identity-name {
color: #526059;
font-size: 0.8rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.identity-role {
color: #8a9790;
font-size: 0.72rem;
}
.filter-rail-body {
display: grid;
gap: 0.85rem;
padding: 0.8rem 0.8rem 1rem;
}
.rail-clear {
width: 100%;
}
.status-band {
@@ -706,39 +801,6 @@
padding-inline: 0.2rem;
}
.filters {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 0.9rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 0.9rem;
}
.filter-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.8rem;
border-bottom: 1px solid var(--color-divider);
}
.filter-head div {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.filter-head strong {
color: var(--color-text-primary);
font-size: 1rem;
font-weight: 700;
}
.section-label,
.field-label {
display: inline-flex;
align-items: center;
@@ -748,13 +810,6 @@
font-weight: 700;
}
.filter-grid {
display: grid;
grid-template-columns: minmax(18rem, 1.4fr) minmax(15rem, 0.95fr) minmax(13rem, 0.8fr) minmax(13rem, 0.9fr) minmax(9rem, 0.5fr);
gap: 0.65rem;
align-items: end;
}
.pagination-bar {
display: flex;
align-items: center;
@@ -1130,8 +1185,7 @@
justify-content: flex-start;
}
.ingredient-grid,
.filter-grid {
.ingredient-grid {
grid-template-columns: 1fr;
}
@@ -1140,14 +1194,28 @@
}
}
@media (max-width: 760px) {
.filter-head {
align-items: stretch;
flex-direction: column;
@media (max-width: 980px) {
.filter-rail {
position: static;
min-height: auto;
height: auto;
border-right: none;
overflow: visible;
}
.filter-head .clear-button {
width: 100%;
.filter-rail-body {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: end;
}
.filter-search {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.filter-rail-body {
grid-template-columns: 1fr;
}
.facts {
+11 -6
View File
@@ -11,6 +11,7 @@ const apiMocks = vi.hoisted(() => ({
productCosts: vi.fn(),
scenarios: vi.fn(),
dataQuality: vi.fn(),
dashboardSummary: vi.fn(),
clientAccess: vi.fn(),
clientAccessExport: vi.fn()
}));
@@ -61,6 +62,13 @@ describe('route loaders use the SvelteKit fetch argument', () => {
apiMocks.productCosts.mockResolvedValue([{ id: 4 }]);
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
apiMocks.dataQuality.mockResolvedValue([{ id: 6 }]);
apiMocks.dashboardSummary.mockResolvedValue({
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] },
operations: null
});
apiMocks.clientAccess.mockResolvedValue([{ id: 7 }]);
apiMocks.clientAccessExport.mockResolvedValue({
generated_at: '',
@@ -74,13 +82,10 @@ describe('route loaders use the SvelteKit fetch argument', () => {
});
it('passes fetch through the home page loader', async () => {
await homeLoad({ fetch: fetcher } as never);
const result = homeLoad({ fetch: fetcher } as never);
await result.summary;
expect(apiMocks.rawMaterials).toHaveBeenCalledWith(fetcher);
expect(apiMocks.mixes).toHaveBeenCalledWith(fetcher);
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
expect(apiMocks.scenarios).toHaveBeenCalledWith(fetcher);
expect(apiMocks.dataQuality).toHaveBeenCalledWith(fetcher);
expect(apiMocks.dashboardSummary).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the raw materials loader', async () => {
@@ -63,7 +63,7 @@
<thead>
<tr>
<th>Session</th>
<th>Client / Product</th>
<th>Client / Mix</th>
<th>Batch</th>
<th>Bags</th>
<th>Prepared by</th>
@@ -78,7 +78,7 @@
<strong>{session.session_number}</strong>
<span>{session.mix_name}</span>
</td>
<td data-label="Client / Product">
<td data-label="Client / Mix">
<strong>{session.product_name}</strong>
<span>{session.client_name}</span>
</td>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { canOpenProductCosting, getWorkspaceHomeHref } from '$lib/workspace-access';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return { items: [], inputs: null };
}
const session = getStoredClientSession();
if (!canOpenProductCosting(session)) {
throw redirect(307, getWorkspaceHomeHref(session));
}
try {
const canRead = hasModuleAccess(session, 'products') || session?.role === 'internal';
if (!canRead) {
return { items: [], inputs: null };
}
const [items, inputs] = await Promise.all([
api.productCostingItems(fetch),
api.productCostingInputs(fetch)
]);
return { items, inputs };
} catch {
return { items: [], inputs: null };
}
}
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@
} from '$lib/types';
import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte';
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
import { toNum } from '$lib/format';
let { data } = $props<{ data: { products: ThroughputProduct[] } }>();
const products = $derived(data.products ?? []);
@@ -53,13 +54,6 @@
}
});
function toNum(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
function resetExceptDateAndStaff() {
productId = '';
bagSize = '';