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_NAME=Hunter Premium Produce
CLIENT_EMAIL=alex@lean-101.com CLIENT_EMAIL=alex@lean-101.com
CLIENT_PASSWORD=JBBwVCDqmPA7 CLIENT_PASSWORD=JBBwVCDqmPA7
+1 -1
View File
@@ -1,4 +1,4 @@
APP_NAME=Lean 101 Clients API APP_NAME=Hunter App
APP_ENV=alpha APP_ENV=alpha
CLIENT_NAME=Hunter Premium Produce CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=operator@example.com 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 APP_ENV=production
CLIENT_NAME=Hunter Premium Produce CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=operator@example.com 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 APP_ENV=production
CLIENT_NAME=Hunter Premium Produce CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=operator@example.com 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 __future__ import annotations
from datetime import date
import json
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload 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.db.session import get_db
from app.models.mix import Mix from app.models.mix import Mix
from app.models.product import Product from app.models.product import Product
from app.models.product_costing import ProductCostItem
from app.models.raw_material import RawMaterial 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.client_access_service import has_access_level
from app.services.costing_engine import ( from app.services.costing_engine import (
calculate_mix_cost, calculate_mix_cost,
@@ -33,6 +38,166 @@ def _can(session: AuthSession, module_key: str) -> bool:
return has_access_level(permissions.get(module_key), "view") 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") @router.get("/summary")
def dashboard_summary( def dashboard_summary(
session: AuthSession = Depends(require_client_module_access("dashboard")), session: AuthSession = Depends(require_client_module_access("dashboard")),
@@ -44,6 +209,7 @@ def dashboard_summary(
raw_series: list[float] = [] raw_series: list[float] = []
mix_series: list[float] = [] mix_series: list[float] = []
product_series: list[float] = [] product_series: list[float] = []
operations_summary = _operations_summary(session, db)
if _can(session, "raw_materials") or _can(session, "dashboard"): if _can(session, "raw_materials") or _can(session, "dashboard"):
materials = db.scalars( materials = db.scalars(
@@ -147,4 +313,5 @@ def dashboard_summary(
"mix_cost_per_kg": mix_series, "mix_cost_per_kg": mix_series,
"product_finished_delivered": product_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 @classmethod
def from_env(cls) -> "Settings": def from_env(cls) -> "Settings":
settings = cls( 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")), app_env=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")),
host=os.getenv("HOST", "0.0.0.0"), host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "8000")), port=int(os.getenv("PORT", "8000")),
+6
View File
@@ -29,6 +29,12 @@ TENANT_TABLES = {
"mix_calculator_sessions": None, "mix_calculator_sessions": None,
"mix_calculator_session_lines": None, "mix_calculator_session_lines": None,
"products": 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, "scenarios": None,
"costing_results": None, "costing_results": None,
"process_cost_rules": 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.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_router from app.api.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_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.products import router as products_router
from app.api.raw_materials import router as raw_materials_router from app.api.raw_materials import router as raw_materials_router
from app.api.scenarios import router as scenarios_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(raw_materials_router)
app.include_router(mixes_router) app.include_router(mixes_router)
app.include_router(mix_calculator_router) app.include_router(mix_calculator_router)
app.include_router(product_costing_router)
app.include_router(products_router) app.include_router(products_router)
app.include_router(scenarios_router) app.include_router(scenarios_router)
app.include_router(throughput_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_calculator import MixCalculatorSession, MixCalculatorSessionLine
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient 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.raw_material import RawMaterial, RawMaterialPriceVersion
from app.models.scenario import CostingResult, Scenario from app.models.scenario import CostingResult, Scenario
from app.models.throughput import ProductionThroughput, ThroughputProduct from app.models.throughput import ProductionThroughput, ThroughputProduct
@@ -24,6 +32,12 @@ __all__ = [
"Permission", "Permission",
"ProcessCostRule", "ProcessCostRule",
"Product", "Product",
"ProductCostBagInput",
"ProductCostBaseInput",
"ProductCostClientInput",
"ProductCostFreightInput",
"ProductCostItem",
"ProductCostProcessInput",
"ProductIngredient", "ProductIngredient",
"ProductionThroughput", "ProductionThroughput",
"ThroughputProduct", "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.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient 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.raw_material import RawMaterial, RawMaterialPriceVersion
from app.models.throughput import ProductionThroughput, ThroughputProduct from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.seed_access import seed_access from app.seed_access import seed_access
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role 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 import_workbook as import_throughput_workbook
from app.services.throughput_service import resolve_workbook_path as resolve_throughput_workbook_path 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" TENANT_ID = "hunter-premium-produce"
@@ -691,7 +705,36 @@ def _upsert_product_ingredients(
for key, formula in product_ingredient_rows.items(): for key, formula in product_ingredient_rows.items():
matched_products = products_by_formula_key.get(key, []) matched_products = products_by_formula_key.get(key, [])
if not matched_products: 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: for product in matched_products:
existing_ingredients = { existing_ingredients = {
@@ -1060,6 +1103,174 @@ def seed_throughput_products(db):
return 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(): def seed_startup_basics():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with SessionLocal() as db: with SessionLocal() as db:
@@ -1069,6 +1280,9 @@ def seed_startup_basics():
report = seed_product_ingredients_from_workbook(db) report = seed_product_ingredients_from_workbook(db)
if report["backfilled"]: if report["backfilled"]:
logger.info("Product ingredients backfilled from workbook: %s", report) 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() db.commit()
+2 -2
View File
@@ -239,7 +239,7 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
current_y, current_y,
detail_width, detail_width,
detail_height, detail_height,
"Product", "Mix",
session_record.product_name, session_record.product_name,
value_font_size=12, value_font_size=12,
) )
@@ -251,7 +251,7 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
current_y, current_y,
detail_width, detail_width,
detail_height, detail_height,
"Mix source", "Formula source",
session_record.mix_name, session_record.mix_name,
value_font_size=11, 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( def calculate_mix_calculator_preview(
db: Session, db: Session,
*, *,
@@ -117,12 +132,15 @@ def calculate_mix_calculator_preview(
} }
) )
mix_label = _mix_calculator_label(product)
return { return {
"client_name": product.client_name, "client_name": product.client_name,
"product_id": product.id, "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_id": product.mix_id,
"mix_name": product.mix.name if product.mix else product.name, "mix_name": mix_label,
"mix_date": values["mix_date"], "mix_date": values["mix_date"],
"batch_size_kg": round(batch_size_kg, 4), "batch_size_kg": round(batch_size_kg, 4),
"total_bags": total_bags, "total_bags": total_bags,
@@ -156,21 +174,43 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
).all() ).all()
mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows} 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( products = db.scalars(
select(Product) 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)) .options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name) .order_by(Product.client_name, Product.name)
).all() ).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}) clients = sorted({product.client_name for product in products})
product_rows = [ product_rows = [
{ {
"product_id": product.id, "product_id": product.id,
"client_name": product.client_name, "client_name": product.client_name,
"product_name": product.name, "product_name": _mix_calculator_label(product),
"mix_id": product.mix_id, "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_of_measure": product.unit_of_measure,
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4), "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)), "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 Metadata-Version: 2.4
Name: data-entry-app-backend Name: data-entry-app-backend
Version: 0.1.5 Version: 0.1.12
Summary: Costing platform MVP backend Summary: Costing platform MVP backend
Requires-Python: >=3.11 Requires-Python: >=3.11
Requires-Dist: fastapi<1.0,>=0.115 Requires-Dist: fastapi<1.0,>=0.115
Requires-Dist: openpyxl<4.0,>=3.1 Requires-Dist: openpyxl<4.0,>=3.1
Requires-Dist: rich<15.0,>=13.9
Requires-Dist: uvicorn[standard]<1.0,>=0.30 Requires-Dist: uvicorn[standard]<1.0,>=0.30
Requires-Dist: sqlalchemy<3.0,>=2.0 Requires-Dist: sqlalchemy<3.0,>=2.0
Requires-Dist: pydantic<3.0,>=2.8 Requires-Dist: pydantic<3.0,>=2.8
@@ -9,16 +9,23 @@ pyproject.toml
./app/api/client_access.py ./app/api/client_access.py
./app/api/dashboard.py ./app/api/dashboard.py
./app/api/deps.py ./app/api/deps.py
./app/api/editor.py
./app/api/mix_calculator.py ./app/api/mix_calculator.py
./app/api/mixes.py ./app/api/mixes.py
./app/api/powerbi.py ./app/api/powerbi.py
./app/api/product_costing.py
./app/api/products.py ./app/api/products.py
./app/api/raw_materials.py ./app/api/raw_materials.py
./app/api/scenarios.py ./app/api/scenarios.py
./app/api/throughput.py
./app/core/__init__.py ./app/core/__init__.py
./app/core/access.py ./app/core/access.py
./app/core/config.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.py
./app/core/security_logging.py
./app/db/__init__.py ./app/db/__init__.py
./app/db/migrations.py ./app/db/migrations.py
./app/db/session.py ./app/db/session.py
@@ -29,53 +36,96 @@ pyproject.toml
./app/models/mix.py ./app/models/mix.py
./app/models/mix_calculator.py ./app/models/mix_calculator.py
./app/models/product.py ./app/models/product.py
./app/models/product_costing.py
./app/models/raw_material.py ./app/models/raw_material.py
./app/models/scenario.py ./app/models/scenario.py
./app/models/throughput.py
./app/schemas/__init__.py ./app/schemas/__init__.py
./app/schemas/client_access.py ./app/schemas/client_access.py
./app/schemas/editor.py
./app/schemas/mix.py ./app/schemas/mix.py
./app/schemas/mix_calculator.py ./app/schemas/mix_calculator.py
./app/schemas/product.py ./app/schemas/product.py
./app/schemas/product_costing.py
./app/schemas/raw_material.py ./app/schemas/raw_material.py
./app/schemas/scenario.py ./app/schemas/scenario.py
./app/schemas/throughput.py
./app/services/__init__.py ./app/services/__init__.py
./app/services/client_access_service.py ./app/services/client_access_service.py
./app/services/costing_engine.py ./app/services/costing_engine.py
./app/services/mix_calculator_filenames.py ./app/services/mix_calculator_filenames.py
./app/services/mix_calculator_pdf.py ./app/services/mix_calculator_pdf.py
./app/services/mix_calculator_service.py ./app/services/mix_calculator_service.py
./app/services/product_costing_service.py
./app/services/scenario_engine.py ./app/services/scenario_engine.py
./app/services/throughput_service.py
app/__init__.py app/__init__.py
app/main.py app/main.py
app/seed.py app/seed.py
app/seed_access.py
app/api/__init__.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/mixes.py
app/api/powerbi.py app/api/powerbi.py
app/api/product_costing.py
app/api/products.py app/api/products.py
app/api/raw_materials.py app/api/raw_materials.py
app/api/scenarios.py app/api/scenarios.py
app/api/throughput.py
app/core/__init__.py app/core/__init__.py
app/core/access.py
app/core/config.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/__init__.py
app/db/migrations.py
app/db/session.py app/db/session.py
app/models/__init__.py app/models/__init__.py
app/models/access.py
app/models/assumption.py app/models/assumption.py
app/models/client_access.py
app/models/mix.py app/models/mix.py
app/models/mix_calculator.py
app/models/product.py app/models/product.py
app/models/product_costing.py
app/models/raw_material.py app/models/raw_material.py
app/models/scenario.py app/models/scenario.py
app/models/throughput.py
app/schemas/__init__.py app/schemas/__init__.py
app/schemas/client_access.py
app/schemas/editor.py
app/schemas/mix.py app/schemas/mix.py
app/schemas/mix_calculator.py
app/schemas/product.py app/schemas/product.py
app/schemas/product_costing.py
app/schemas/raw_material.py app/schemas/raw_material.py
app/schemas/scenario.py app/schemas/scenario.py
app/schemas/throughput.py
app/services/__init__.py app/services/__init__.py
app/services/client_access_service.py
app/services/costing_engine.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/scenario_engine.py
app/services/throughput_service.py
data_entry_app_backend.egg-info/PKG-INFO data_entry_app_backend.egg-info/PKG-INFO
data_entry_app_backend.egg-info/SOURCES.txt data_entry_app_backend.egg-info/SOURCES.txt
data_entry_app_backend.egg-info/dependency_links.txt data_entry_app_backend.egg-info/dependency_links.txt
data_entry_app_backend.egg-info/requires.txt data_entry_app_backend.egg-info/requires.txt
data_entry_app_backend.egg-info/top_level.txt data_entry_app_backend.egg-info/top_level.txt
tests/test_access.py tests/test_access.py
tests/test_costing_engine.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 fastapi<1.0,>=0.115
openpyxl<4.0,>=3.1 openpyxl<4.0,>=3.1
rich<15.0,>=13.9
uvicorn[standard]<1.0,>=0.30 uvicorn[standard]<1.0,>=0.30
sqlalchemy<3.0,>=2.0 sqlalchemy<3.0,>=2.0
pydantic<3.0,>=2.8 pydantic<3.0,>=2.8
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "data-entry-app-backend" name = "data-entry-app-backend"
version = "0.1.9" version = "0.1.12"
description = "Costing platform MVP backend" description = "Costing platform MVP backend"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ 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") options = build_mix_calculator_options(db, tenant_id="hunter-premium-produce")
assert options["clients"] == ["Peckish"] 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 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) options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
assert options_response.status_code == 200 assert options_response.status_code == 200
options_payload = options_response.json() options_payload = options_response.json()
assert len(options_payload["products"]) >= 100 assert len(options_payload["products"]) == 84
seeded_product = next(product for product in options_payload["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg") 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 assert seeded_product["unit_size_kg"] == 20
create_response = client.post( 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) options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
seeded_product = next( 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( 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 dockerfile: backend/Dockerfile
restart: unless-stopped restart: unless-stopped
environment: 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} DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db}
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce} CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com} CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com}
+1 -1
View File
@@ -23,7 +23,7 @@ services:
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
restart: unless-stopped restart: unless-stopped
environment: environment:
APP_NAME: ${APP_NAME:-Lean 101 Clients API} APP_NAME: ${APP_NAME:-Hunter App}
APP_ENV: ${APP_ENV:-production} APP_ENV: ${APP_ENV:-production}
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}} DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}}
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce} CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
+1 -1
View File
@@ -6,7 +6,7 @@ services:
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
restart: unless-stopped restart: unless-stopped
environment: environment:
APP_NAME: ${APP_NAME:-Lean 101 Clients API} APP_NAME: ${APP_NAME:-Hunter App}
APP_ENV: ${APP_ENV:-development} APP_ENV: ${APP_ENV:-development}
DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db} DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db}
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce} CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.11b", "version": "0.1.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.11b", "version": "0.1.12",
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1" "lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.11b", "version": "0.1.12",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+15
View File
@@ -4,6 +4,21 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.png" /> <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% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+40 -47
View File
@@ -1,16 +1,5 @@
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import {
mockClientAccess,
mockClientAccessExport,
mockCosts,
mockMixCalculatorOptions,
mockMixCalculatorSessions,
mockMixes,
mockProducts,
mockRawMaterials,
mockScenarios
} from '$lib/mock';
import type { import type {
ClientAccessAccount, ClientAccessAccount,
ClientAccessPowerBiExport, ClientAccessPowerBiExport,
@@ -34,6 +23,9 @@ import type {
MixUpdateInput, MixUpdateInput,
Product, Product,
ProductCostBreakdown, ProductCostBreakdown,
ProductCostingInputs,
ProductCostingItem,
ProductCostingItemUpdateInput,
RawMaterial, RawMaterial,
RawMaterialCreateInput, RawMaterialCreateInput,
RawMaterialPriceCreateInput, RawMaterialPriceCreateInput,
@@ -136,23 +128,17 @@ function normalizeRequestError(error: unknown) {
return new Error('An unexpected error occurred while contacting the server.'); 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 { try {
const response = await fetcher(resolveRequestUrl(path, fetcher), { const response = await fetcher(resolveRequestUrl(path, fetcher), {
credentials: 'include' credentials: 'include'
}); });
if (!response.ok) { if (!response.ok) {
if (auth !== 'none') { throw new Error(response.statusText || 'Request failed');
throw new Error(response.statusText || 'Unauthorized');
}
return fallback;
} }
return (await response.json()) as T; return (await response.json()) as T;
} catch (error) { } catch (error) {
if (auth !== 'none') { throw normalizeRequestError(error);
throw normalizeRequestError(error);
}
return fallback;
} }
} }
@@ -172,13 +158,12 @@ function makeCacheKey(path: string, auth: AuthMode) {
async function cachedFetchJson<T>( async function cachedFetchJson<T>(
path: string, path: string,
fallback: T,
auth: AuthMode = 'none', auth: AuthMode = 'none',
fetcher: ApiFetch = fetch fetcher: ApiFetch = fetch
): Promise<T> { ): Promise<T> {
// Bypass the cache during SSR (no localStorage, no shared session). // Bypass the cache during SSR (no localStorage, no shared session).
if (!browser) { if (!browser) {
return fetchJson<T>(path, fallback, auth, fetcher); return fetchJson<T>(path, auth, fetcher);
} }
const key = makeCacheKey(path, auth); const key = makeCacheKey(path, auth);
@@ -194,7 +179,7 @@ async function cachedFetchJson<T>(
return existing as Promise<T>; return existing as Promise<T>;
} }
const promise = fetchJson<T>(path, fallback, auth, fetcher) const promise = fetchJson<T>(path, auth, fetcher)
.then((value) => { .then((value) => {
responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS }); responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS });
return value; return value;
@@ -290,13 +275,13 @@ async function requestBlob(
} }
export const api = { export const api = {
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher), rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, '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), mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorOptions: (fetcher?: ApiFetch) => mixCalculatorOptions: (fetcher?: ApiFetch) =>
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher), cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', 'client', fetcher),
mixCalculatorSessions: (fetcher?: ApiFetch) => mixCalculatorSessions: (fetcher?: ApiFetch) =>
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher), cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', 'client', fetcher),
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) => mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher), request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) => mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) =>
@@ -321,7 +306,7 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, '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) => { editorProducts: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
const search = new URLSearchParams(); const search = new URLSearchParams();
if (params?.q) search.set('q', params.q); if (params?.q) search.set('q', params.q);
@@ -329,7 +314,7 @@ export const api = {
if (params?.limit) search.set('limit', String(params.limit)); if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString(); const qs = search.toString();
const path = qs ? `/api/editor/products?${qs}` : '/api/editor/products'; 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) => updateEditorProduct: (productId: number, payload: EditorProductUpdateInput) =>
request<EditorProductRow>(`/api/editor/products/${productId}`, { request<EditorProductRow>(`/api/editor/products/${productId}`, {
@@ -358,10 +343,28 @@ export const api = {
method: 'DELETE' method: 'DELETE'
}, 'client'), }, 'client'),
productCosts: (fetcher?: ApiFetch) => productCosts: (fetcher?: ApiFetch) =>
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher), cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, '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) => throughputProducts: (fetcher?: ApiFetch) =>
cachedFetchJson<ThroughputProduct[]>('/api/throughput/products', [], 'client', fetcher), cachedFetchJson<ThroughputProduct[]>('/api/throughput/products', 'client', fetcher),
throughputEntries: (params?: ThroughputEntryListParams, fetcher?: ApiFetch) => { throughputEntries: (params?: ThroughputEntryListParams, fetcher?: ApiFetch) => {
const search = new URLSearchParams(); const search = new URLSearchParams();
if (params?.date_from) search.set('date_from', params.date_from); 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)); if (params?.limit) search.set('limit', String(params.limit));
const qs = search.toString(); const qs = search.toString();
const path = qs ? `/api/throughput/entries?${qs}` : '/api/throughput/entries'; 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) => createThroughputEntry: (payload: ThroughputEntryCreateInput) =>
request<ThroughputEntry>('/api/throughput/entries', { request<ThroughputEntry>('/api/throughput/entries', {
@@ -389,22 +392,12 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, '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) => clientAccessExport: (fetcher?: ApiFetch) =>
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher), cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher), dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', 'client', fetcher),
dashboardSummary: (fetcher?: ApiFetch) => dashboardSummary: (fetcher?: ApiFetch) =>
cachedFetchJson<DashboardSummary>( cachedFetchJson<DashboardSummary>('/api/dashboard/summary', 'client', fetcher),
'/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
),
clientLogin: (email: string, password: string) => clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', { request<LoginResponse>('/api/auth/client/login', {
method: 'POST', method: 'POST',
@@ -163,10 +163,8 @@
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-columns: 280px minmax(0, 1fr); grid-template-columns: 280px minmax(0, 1fr);
background: background: var(--color-bg-app);
radial-gradient(circle at top left, rgba(167, 217, 190, 0.22), transparent 34%), color: var(--color-text-primary);
linear-gradient(180deg, #f7f8f4 0%, #eef2ea 100%);
color: #203028;
} }
.admin-sidebar { .admin-sidebar {
+66 -470
View File
@@ -17,6 +17,7 @@
canOpenEditor as sessionCanOpenEditor, canOpenEditor as sessionCanOpenEditor,
canOpenMixCalculator as sessionCanOpenMixCalculator, canOpenMixCalculator as sessionCanOpenMixCalculator,
canOpenMixMaster as sessionCanOpenMixMaster, canOpenMixMaster as sessionCanOpenMixMaster,
canOpenProductCosting as sessionCanOpenProductCosting,
canOpenReporting as sessionCanOpenReporting, canOpenReporting as sessionCanOpenReporting,
canOpenSettings as sessionCanOpenSettings, canOpenSettings as sessionCanOpenSettings,
canOpenThroughput as sessionCanOpenThroughput, canOpenThroughput as sessionCanOpenThroughput,
@@ -28,6 +29,7 @@
import { import {
accessControlItem, accessControlItem,
baseSearchItems, baseSearchItems,
buildClientNavEntries,
clientBreadcrumbs, clientBreadcrumbs,
dashboardItem, dashboardItem,
editorItem, editorItem,
@@ -35,6 +37,7 @@
matchesRoute, matchesRoute,
mixCalculatorItem, mixCalculatorItem,
pageTitle, pageTitle,
productCostingItem,
reportingItem, reportingItem,
throughputItem, throughputItem,
type FooterLink, type FooterLink,
@@ -96,10 +99,27 @@
}) })
); );
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null); const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession)); const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null); const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null); const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
const visibleEditorItem = $derived(canOpenEditor ? editorItem : 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 isOperationsUser = $derived($clientSession?.role_name === 'Operations');
const workspaceRole = $derived(getWorkspaceRole($clientSession)); const workspaceRole = $derived(getWorkspaceRole($clientSession));
const visibleFooterLinks = $derived([ const visibleFooterLinks = $derived([
@@ -112,6 +132,7 @@
[ [
...(visibleDashboardItem ? [visibleDashboardItem] : []), ...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []), ...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...visibleWorkingDocumentItems.slice(0, 2) ...visibleWorkingDocumentItems.slice(0, 2)
] ]
); );
@@ -124,6 +145,7 @@
if (item.href === '/mixes') return canOpenMixMaster; if (item.href === '/mixes') return canOpenMixMaster;
if (item.href === '/mixes/new') return canCreateMixWorksheet; if (item.href === '/mixes/new') return canCreateMixWorksheet;
if (item.href === '/mix-calculator') return canOpenMixCalculator; if (item.href === '/mix-calculator') return canOpenMixCalculator;
if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession);
if (item.href === '/editor') return canOpenEditor; if (item.href === '/editor') return canOpenEditor;
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession); if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
if (item.href === '/settings') return canOpenSettings; if (item.href === '/settings') return canOpenSettings;
@@ -377,15 +399,8 @@
{#if !showBottomNav} {#if !showBottomNav}
<ClientPrimaryRail <ClientPrimaryRail
currentPath={shellPathname} currentPath={shellPathname}
primaryItems={[ entries={navEntries}
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleThroughputItem ? [visibleThroughputItem] : []),
...(visibleReportingItem ? [visibleReportingItem] : [])
]}
brandHref={workspaceHomeHref} brandHref={workspaceHomeHref}
workingDocumentItems={visibleWorkingDocumentItems}
footerItems={visibleFooterLinks} footerItems={visibleFooterLinks}
{appVersion} {appVersion}
{currentYear} {currentYear}
@@ -528,6 +543,15 @@
</a> </a>
{/if} {/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} {#if visibleThroughputItem}
{@const Icon = visibleThroughputItem.icon} {@const Icon = visibleThroughputItem.icon}
<a class:active={matchesRoute(visibleThroughputItem.href, page.url.pathname)} href={visibleThroughputItem.href} onclick={() => (navOpen = false)}> <a class:active={matchesRoute(visibleThroughputItem.href, page.url.pathname)} href={visibleThroughputItem.href} onclick={() => (navOpen = false)}>
@@ -659,433 +683,9 @@
{/if} {/if}
<style> <style>
:global(:root) { /* Design tokens and the shared .ui-* surface classes now live in
/* ── Brand ──────────────────────────────────────────────── */ $lib/styles/theme.css (imported once in the root layout) so every shell,
--color-brand: oklch(0.54 0.15 149); client / admin / error, themes consistently in light and dark. */
--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;
}
}
.app-shell { .app-shell {
display: grid; display: grid;
@@ -1126,7 +726,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
color: #6d7d74; color: var(--color-text-muted);
background: transparent; background: transparent;
border-radius: 0.55rem; border-radius: 0.55rem;
width: 1.6rem; width: 1.6rem;
@@ -1210,13 +810,13 @@
} }
.search-placeholder { .search-placeholder {
color: #93a098; color: var(--color-text-muted);
} }
.search-icon { .search-icon {
width: 0.82rem; width: 0.82rem;
height: 0.82rem; height: 0.82rem;
border: 2px solid #98a59d; border: 2px solid var(--color-text-muted);
border-radius: 999px; border-radius: 999px;
} }
@@ -1228,7 +828,7 @@
width: 0.42rem; width: 0.42rem;
height: 2px; height: 2px;
border-radius: 999px; border-radius: 999px;
background: #98a59d; background: var(--color-text-muted);
transform: rotate(45deg); transform: rotate(45deg);
} }
@@ -1237,7 +837,7 @@
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 0.42rem; border-radius: 0.42rem;
color: var(--muted); color: var(--muted);
background: #fff; background: var(--color-bg-surface);
font-size: 0.76rem; font-size: 0.76rem;
} }
@@ -1257,7 +857,7 @@
border: none; border: none;
border-radius: 0.82rem; border-radius: 0.82rem;
background: transparent; background: transparent;
color: #304038; color: var(--color-text-primary);
cursor: pointer; cursor: pointer;
transition: background-color 160ms ease; 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` is kept for the bottom-nav (still uses letter labels). */
.nav-icon.muted { .nav-icon.muted {
color: #fff; color: var(--color-on-brand);
background: linear-gradient(135deg, #95a39b 0%, #6e7c73 100%); background: var(--color-brand);
} }
.main-shell { .main-shell {
@@ -1334,9 +934,8 @@
padding: 0.4rem; padding: 0.4rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.96rem; border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.98); background: var(--color-bg-elevated);
box-shadow: 0 4px 12px rgba(0,0,0,0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10px);
} }
.quick-fab-wrap { .quick-fab-wrap {
@@ -1357,7 +956,7 @@
border: none; border: none;
border-radius: 999px; border-radius: 999px;
background: var(--color-brand); background: var(--color-brand);
color: #fff; color: var(--color-on-brand);
box-shadow: none; box-shadow: none;
font-weight: 700; font-weight: 700;
letter-spacing: 0.01em; letter-spacing: 0.01em;
@@ -1408,7 +1007,7 @@
.menu-panel button { .menu-panel button {
padding: 0.72rem 0.78rem; padding: 0.72rem 0.78rem;
border-radius: 0.78rem; border-radius: 0.78rem;
color: #304038; color: var(--color-text-primary);
text-align: left; text-align: left;
background: transparent; background: transparent;
border: none; border: none;
@@ -1585,11 +1184,10 @@
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem; gap: 0.5rem;
padding: 0.6rem; padding: 0.6rem;
border: 1px solid rgba(217, 228, 221, 0.92); border: 1px solid var(--color-border);
border-radius: 1.35rem; border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.96); background: var(--color-bg-surface);
box-shadow: 0 4px 12px rgba(0,0,0,0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(16px);
} }
.quick-fab-wrap { .quick-fab-wrap {
@@ -1606,7 +1204,7 @@
border: none; border: none;
border-radius: 1rem; border-radius: 1rem;
background: transparent; background: transparent;
color: #51635a; color: var(--color-text-secondary);
text-align: center; text-align: center;
font-size: 0.74rem; font-size: 0.74rem;
font-weight: 700; font-weight: 700;
@@ -1626,14 +1224,14 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 0.78rem; border-radius: 0.78rem;
color: #fff; color: var(--color-on-brand);
background: var(--green-deep); background: var(--color-brand);
font-size: 0.66rem; font-size: 0.66rem;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.bottom-nav-icon.muted { .bottom-nav-icon.muted {
background: #8b949e; background: var(--color-text-muted);
} }
.bottom-drawer { .bottom-drawer {
@@ -1647,10 +1245,8 @@
padding: 0.85rem 1rem calc(6.6rem + env(safe-area-inset-bottom)); padding: 0.85rem 1rem calc(6.6rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
border-radius: 1.6rem 1.6rem 0 0; border-radius: 1.6rem 1.6rem 0 0;
background: background: var(--color-bg-surface);
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);
box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
backdrop-filter: blur(16px);
} }
.drawer-handle { .drawer-handle {
@@ -1658,7 +1254,7 @@
height: 0.34rem; height: 0.34rem;
margin: 0 auto; margin: 0 auto;
border-radius: 999px; border-radius: 999px;
background: #c8d4ce; background: var(--color-border);
} }
.drawer-header { .drawer-header {
@@ -1673,7 +1269,7 @@
} }
:global(.drawer-search) { :global(.drawer-search) {
background: #fff; background: var(--color-bg-surface);
} }
.drawer-grid { .drawer-grid {
@@ -1695,23 +1291,23 @@
padding: 0.82rem 0.86rem; padding: 0.82rem 0.86rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.96rem; border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.88); background: var(--color-bg-surface);
color: #304038; color: var(--color-text-primary);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
.drawer-section a.active { .drawer-section a.active {
color: var(--green-deep); color: var(--color-brand-hover);
background: var(--green-soft); background: color-mix(in srgb, var(--color-brand) 11%, var(--color-bg-surface));
} }
.drawer-badge { .drawer-badge {
margin-left: auto; margin-left: auto;
padding: 0.08rem 0.4rem; padding: 0.08rem 0.4rem;
border-radius: 999px; border-radius: 999px;
background: #fdf0d2; background: var(--color-warning-tint);
color: #8a5a00; color: var(--color-warning-text);
font-size: 0.62rem; font-size: 0.62rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@@ -1747,8 +1343,8 @@
padding: 0.82rem 0.9rem; padding: 0.82rem 0.9rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.96rem; border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.88); background: var(--color-bg-surface);
color: #304038; color: var(--color-text-primary);
font-weight: 600; 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>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>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>Client</span><strong>{session.client_name}</strong></div>
<div><span>Product</span><strong>{session.product_name}</strong></div> <div><span>Mix</span><strong>{session.product_name}</strong></div>
<div><span>Mix source</span><strong>{session.mix_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>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> <div><span>Batch size</span><strong>{formatNumber(session.batch_size_kg, 2)}kg</strong></div>
</section> </section>
@@ -1,23 +1,62 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
import type { MixCalculatorSession } from '$lib/types'; 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( const printableTitle = $derived(
`MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_') `MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_')
); );
async function openPdf() { function revokePdfUrl() {
const blob = await api.mixCalculatorSessionPdf(session.id); if (pdfUrl) {
const url = URL.createObjectURL(blob); URL.revokeObjectURL(pdfUrl);
window.open(url, '_blank', 'noopener,noreferrer'); pdfUrl = null;
setTimeout(() => URL.revokeObjectURL(url), 60_000); }
}
async function loadPdf() {
loading = true;
error = '';
revokePdfUrl();
try {
const blob = await api.mixCalculatorSessionPdf(session.id);
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() { 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 url = URL.createObjectURL(blob);
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = url; anchor.href = url;
@@ -27,6 +66,14 @@
anchor.remove(); anchor.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
onMount(() => {
loadPdf();
});
onDestroy(() => {
revokePdfUrl();
});
</script> </script>
<svelte:head> <svelte:head>
@@ -36,11 +83,28 @@
<section class="print-page"> <section class="print-page">
<div class="print-toolbar"> <div class="print-toolbar">
<a class="secondary-button" href={`/mix-calculator/${session.id}`}>Back to session</a> <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="primary-button" type="button" disabled={!pdfUrl && loading} onclick={printPage}>Print</button>
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button> <button class="secondary-button" type="button" disabled={!pdfUrl && loading} onclick={downloadPdf}>Download PDF</button>
</div> </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> </section>
<style> <style>
@@ -57,6 +121,7 @@
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 0.75rem; gap: 0.75rem;
width: min(100%, 210mm);
} }
.primary-button, .primary-button,
@@ -82,6 +147,52 @@
color: #304038; 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) { @media (max-width: 900px) {
.print-toolbar { .print-toolbar {
justify-content: stretch; justify-content: stretch;
@@ -93,16 +204,9 @@
} }
@media print { @media print {
:global(body) { .print-page,
background: #fff; .print-toolbar,
} .pdf-preview-shell {
.print-page {
padding: 0;
background: #fff;
}
.print-toolbar {
display: none; 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"> <script lang="ts">
import { onDestroy } from 'svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import MixCalculatorPrintDocument from '$lib/components/MixCalculatorPrintDocument.svelte';
import { featureFlags } from '$lib/features'; import { featureFlags } from '$lib/features';
import { formatNumber } from '$lib/format';
import { clientSession, hasModuleAccess } from '$lib/session'; import { clientSession, hasModuleAccess } from '$lib/session';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import type { import type {
@@ -52,8 +53,11 @@
let notes = $state(initialNotesValue()); let notes = $state(initialNotesValue());
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue()); let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
let formError = $state(''); 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 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 canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
const availableClients = $derived( 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 { function buildPayload(): MixCalculatorCreateInput | null {
formError = ''; formError = '';
formHint = ''; formHint = '';
@@ -115,13 +111,13 @@
return null; return null;
} }
if (!clientName) { if (!clientName) {
formError = 'Select a client to unlock matching products.'; formError = 'Select a client to unlock matching mixes.';
formHint = 'Products stay disabled until a client is selected.'; formHint = 'Mixes stay disabled until a client is selected.';
return null; return null;
} }
if (!productId) { if (!productId) {
formError = 'Select a product.'; formError = 'Select a mix.';
formHint = 'Pick one of the products available for the selected client.'; formHint = 'Pick one of the mixes available for the selected client.';
return null; return null;
} }
if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) { if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) {
@@ -171,26 +167,26 @@
notes = ''; notes = '';
preview = null; preview = null;
formError = ''; 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(() => { $effect(() => {
if (!clientName) { if (!clientName) {
formHint = 'Select a client to unlock the product list.'; formHint = 'Select a client to unlock the mix list.';
return; return;
} }
if (!filteredProducts.length) { if (!filteredProducts.length) {
formHint = `No products are available for ${clientName}.`; formHint = `No mixes are available for ${clientName}.`;
return; return;
} }
if (!productId) { if (!productId) {
formHint = 'Select a product for the chosen client.'; formHint = 'Select a mix for the chosen client.';
return; return;
} }
formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected product'}.`; formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected mix'}.`;
}); });
async function downloadPdf() { async function downloadPdf() {
@@ -219,27 +215,53 @@
} }
} }
async function openPdf() { function revokePrintPdfUrl() {
const tid = toast.loading('Opening styled PDF…'); if (printPdfUrl) {
try { URL.revokeObjectURL(printPdfUrl);
const payload = buildPayload(); printPdfUrl = null;
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);
toast.dismiss(tid);
} catch (error) {
toast.dismiss(tid);
toast.error(error instanceof Error ? error.message : 'Unable to open the styled PDF.');
} }
} }
async function printCurrent() {
if (!preview) {
toast.error('Calculate the mix before printing.');
return;
}
const payload = buildPayload();
if (!payload) {
toast.error(formError || 'Complete the mix details first.');
return;
}
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 prepare the PDF for printing.');
}
}
function printLoadedPdf() {
printFrame?.contentWindow?.focus();
printFrame?.contentWindow?.print();
}
function handlePrintFrameLoad() {
if (printAfterPdfLoad) {
printAfterPdfLoad = false;
requestAnimationFrame(printLoadedPdf);
}
}
onDestroy(() => {
revokePrintPdfUrl();
});
</script> </script>
{#if !canEdit && !initialSession} {#if !canEdit && !initialSession}
@@ -258,8 +280,7 @@
<a class="secondary-button" href="/mix-calculator">Session history</a> <a class="secondary-button" href="/mix-calculator">Session history</a>
{/if} {/if}
{#if initialSession} {#if initialSession}
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Open PDF page</a> <a class="primary-button" href={`/mix-calculator/${initialSession.id}/print`}>Print</a>
<button class="primary-button" type="button" onclick={openPdf}>Open PDF in new tab</button>
<button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button> <button class="secondary-button" type="button" onclick={downloadPdf}>Download PDF</button>
{/if} {/if}
</section> </section>
@@ -270,7 +291,7 @@
<div class="section-header"> <div class="section-header">
<div> <div>
<h3>Session Inputs</h3> <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> </div>
{#if selectedProduct} {#if selectedProduct}
<div class="product-pill"> <div class="product-pill">
@@ -301,7 +322,7 @@
<label> <label>
<span>Client</span> <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> <option value="">Select a client</option>
{#each availableClients as client} {#each availableClients as client}
<option value={client}>{client}</option> <option value={client}>{client}</option>
@@ -310,16 +331,16 @@
</label> </label>
<label> <label>
<span>Product</span> <span>Mix Name</span>
<select <select
bind:value={productId} bind:value={productId}
disabled={!canEdit || !clientName || !filteredProducts.length} 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} {#each filteredProducts as product}
<option value={product.product_id}> <option value={product.product_id}>
{product.product_name} · {product.mix_name} · {product.unit_of_measure} {product.product_name}
</option> </option>
{/each} {/each}
</select> </select>
@@ -338,8 +359,8 @@
{#if canEdit && selectedProduct} {#if canEdit && selectedProduct}
<div class="calculation-note"> <div class="calculation-note">
<strong>Source mix</strong> <strong>Formula source</strong>
<span>{selectedProduct.mix_name} totals {formatNumber(selectedProduct.mix_total_kg, 2)}kg. Scale factor = batch size / source mix total.</span> <span>{selectedProduct.product_name} totals {formatNumber(selectedProduct.mix_total_kg, 2)}kg. Scale factor = batch size / source formula total.</span>
</div> </div>
{/if} {/if}
@@ -361,15 +382,19 @@
<MixCalculatorResultsPanel <MixCalculatorResultsPanel
preview={preview} preview={preview}
sessionNumber={initialSession?.session_number ?? null} sessionNumber={initialSession?.session_number ?? null}
onOpenPdf={preview ? openPdf : null} onPrint={preview ? printCurrent : null}
onDownloadPdf={preview ? downloadPdf : null} onDownloadPdf={preview ? downloadPdf : null}
/> />
</section> </section>
{#if preview} {#if printPdfUrl}
<section class="print-only" aria-hidden="true"> <iframe
<MixCalculatorPrintDocument session={preview} /> bind:this={printFrame}
</section> class="print-pdf-frame"
src={printPdfUrl}
title="Mix calculator PDF print frame"
onload={handlePrintFrameLoad}
></iframe>
{/if} {/if}
{/if} {/if}
@@ -636,33 +661,14 @@
} }
} }
.print-only { .print-pdf-frame {
display: none; position: fixed;
} right: 0;
bottom: 0;
@media print { width: 1px;
:global(body) { height: 1px;
background: #fff !important; border: 0;
margin: 0 !important; opacity: 0;
} pointer-events: none;
: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;
}
} }
</style> </style>
@@ -1,32 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Download, Printer } from 'lucide-svelte'; import { Download, Printer } from 'lucide-svelte';
import { formatDate, formatNumber } from '$lib/format';
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types'; import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
let { let {
preview, preview,
sessionNumber = null, sessionNumber = null,
onOpenPdf = null, onPrint = null,
onDownloadPdf = null onDownloadPdf = null
}: { }: {
preview: MixCalculatorPreview | MixCalculatorSession | null; preview: MixCalculatorPreview | MixCalculatorSession | null;
sessionNumber?: string | null; sessionNumber?: string | null;
onOpenPdf?: (() => void) | null; onPrint?: (() => void) | null;
onDownloadPdf?: (() => void) | null; onDownloadPdf?: (() => void) | null;
} = $props(); } = $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> </script>
<article class="result-card"> <article class="result-card">
@@ -76,11 +64,11 @@
<strong>{preview.client_name}</strong> <strong>{preview.client_name}</strong>
</div> </div>
<div> <div>
<span>Product</span> <span>Mix</span>
<strong>{preview.product_name}</strong> <strong>{preview.product_name}</strong>
</div> </div>
<div> <div>
<span>Mix source</span> <span>Formula source</span>
<strong>{preview.mix_name}</strong> <strong>{preview.mix_name}</strong>
</div> </div>
<div> <div>
@@ -111,7 +99,7 @@
</div> </div>
<div class="output-actions"> <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" /> <Printer size={18} strokeWidth={1.9} aria-hidden="true" />
Print Print
</button> </button>
@@ -132,7 +120,7 @@
<span></span><span></span><span></span> <span></span><span></span><span></span>
</div> </div>
<strong>No calculation yet</strong> <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>
<div class="empty-shimmer-rows"> <div class="empty-shimmer-rows">
{#each [1,2,3,4,5] as _} {#each [1,2,3,4,5] as _}
@@ -73,7 +73,7 @@
border: none; border: none;
border-radius: 0.7rem; border-radius: 0.7rem;
background: transparent; 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-size: var(--nav-item-size, 0.93rem);
font-weight: var(--nav-item-weight, 500); font-weight: var(--nav-item-weight, 500);
text-align: left; text-align: left;
@@ -83,14 +83,14 @@
.nav-list a:hover, .nav-list a:hover,
.nav-button:hover { .nav-button:hover {
background: var(--nav-item-hover-bg, var(--panel-soft)); background: var(--nav-item-hover-bg, var(--color-surface-hover));
color: var(--nav-item-hover-color, #304038); color: var(--nav-item-hover-color, var(--color-text-primary));
} }
.nav-list a.active, .nav-list a.active,
.nav-button.active { .nav-button.active {
background: var(--nav-item-active-bg, var(--color-brand)); 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); font-weight: var(--nav-item-active-weight, 600);
} }
@@ -111,8 +111,8 @@
flex-shrink: 0; flex-shrink: 0;
padding: 0.08rem 0.4rem; padding: 0.08rem 0.4rem;
border-radius: 999px; border-radius: 999px;
background: #fdf0d2; background: var(--color-warning-tint);
color: #8a5a00; color: var(--color-warning-text);
font-size: 0.62rem; font-size: 0.62rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@@ -122,8 +122,8 @@
.nav-list a.active .nav-badge, .nav-list a.active .nav-badge,
.nav-button.active .nav-badge { .nav-button.active .nav-badge {
background: rgba(255, 255, 255, 0.92); background: var(--color-warning-tint);
color: #8a5a00; color: var(--color-warning-text);
} }
.nav-icon { .nav-icon {
@@ -133,18 +133,18 @@
flex-shrink: 0; flex-shrink: 0;
width: 1.6rem; width: 1.6rem;
height: 1.6rem; height: 1.6rem;
color: var(--nav-icon-color, #6d7d74); color: var(--nav-icon-color, var(--color-text-muted));
border-radius: 0.55rem; border-radius: 0.55rem;
transition: color 140ms ease; transition: color 140ms ease;
} }
.nav-list a:hover .nav-icon, .nav-list a:hover .nav-icon,
.nav-button: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-list a.active .nav-icon,
.nav-button.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> </style>
@@ -75,23 +75,23 @@
height: 100%; height: 100%;
min-height: calc(100vh - 8.5rem); min-height: calc(100vh - 8.5rem);
padding: 0; 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); border-right: 1px solid var(--line);
overflow-y: auto; 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-size: 0.66rem;
--nav-section-label-spacing: 0.14em; --nav-section-label-spacing: 0.14em;
--nav-item-color: #66756d; --nav-item-color: var(--color-text-secondary);
--nav-item-size: 0.88rem; --nav-item-size: 0.88rem;
--nav-item-weight: 450; --nav-item-weight: 450;
--nav-item-hover-bg: color-mix(in srgb, var(--panel) 72%, transparent); --nav-item-hover-bg: var(--color-surface-hover);
--nav-item-hover-color: #425148; --nav-item-hover-color: var(--color-text-primary);
--nav-item-active-bg: color-mix(in srgb, var(--color-brand) 7%, transparent); --nav-item-active-bg: color-mix(in srgb, var(--color-brand) 11%, transparent);
--nav-item-active-color: #22352d; --nav-item-active-color: var(--color-brand-hover);
--nav-item-active-weight: 560; --nav-item-active-weight: 560;
--nav-item-active-marker: color-mix(in srgb, var(--color-brand) 28%, transparent); --nav-item-active-marker: color-mix(in srgb, var(--color-brand) 32%, transparent);
--nav-icon-color: #8a9790; --nav-icon-color: var(--color-text-muted);
--nav-icon-hover-color: #607067; --nav-icon-hover-color: var(--color-text-secondary);
--nav-icon-active-color: var(--color-brand); --nav-icon-active-color: var(--color-brand);
} }
@@ -1,14 +1,19 @@
<script lang="ts"> <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 {
import { matchesRoute, type FooterLink, type NavItem } from '$lib/navigation/client-navigation'; groupHasActiveChild,
matchesRoute,
type FooterLink,
type NavEntry,
type NavItem
} from '$lib/navigation/client-navigation';
let { let {
brandHref, brandHref,
currentPath, currentPath,
primaryItems, entries,
workingDocumentItems,
footerItems, footerItems,
appVersion, appVersion,
currentYear, currentYear,
@@ -18,8 +23,7 @@
}: { }: {
brandHref: string; brandHref: string;
currentPath: string; currentPath: string;
primaryItems: NavItem[]; entries: NavEntry[];
workingDocumentItems: NavItem[];
footerItems: FooterLink[]; footerItems: FooterLink[];
appVersion: string; appVersion: string;
currentYear: number; currentYear: number;
@@ -27,76 +31,156 @@
onOpenSettings: () => void; onOpenSettings: () => void;
onSignOut: () => void; onSignOut: () => void;
} = $props(); } = $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> </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"> <aside class="sidebar">
<div class="brand-row"> <div class="brand-row">
<a class="brand" href={brandHref}> <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> </a>
<span class="module-pill">{moduleCount} modules</span>
</div> </div>
<div class="sidebar-body"> <div class="sidebar-body">
<AppNavSection <div class="rail-scroll">
label="Modules" <div class="rail-section-head">
ariaLabel="Client navigation" <p class="rail-section-label">Modules</p>
items={primaryItems.map((item) => ({ <span class="rail-section-count">{moduleCount}</span>
label: item.label, </div>
href: item.href,
icon: item.icon,
badge: item.badge,
active: matchesRoute(item.href, currentPath)
}))}
/>
{#if workingDocumentItems.length} <nav class="rail-nav" aria-label="Workspace navigation">
<AppNavSection {#each entries as entry}
label="Working Docs" {#if entry.kind === 'item'}
ariaLabel="Working document pages" {@render leafLink(entry.item, true)}
items={workingDocumentItems.map((item) => ({ {:else}
label: item.label, {@const group = entry.group}
href: item.href, {@const GroupIcon = group.icon}
icon: item.icon, {@const groupActive = groupHasActiveChild(group, currentPath)}
active: matchesRoute(item.href, currentPath) {@const open = isOpen(group.id)}
}))} <div class="rail-group">
/> <button
{/if} 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} {#if open}
<AppNavSection <div class="rail-children">
label="More" {#each group.children as child}
ariaLabel="Workspace shortcuts" {@render leafLink(child, false)}
items={footerItems.map((item) => ({ {/each}
label: item.label, </div>
href: item.href, {/if}
icon: item.icon </div>
}))} {/if}
/> {/each}
{/if} </nav>
</div>
<div class="sidebar-meta"> <div class="sidebar-meta">
<AppNavSection <div class="rail-nav">
ariaLabel="Account actions" {#each footerItems as item}
items={[ {@render leafLink(item as NavItem, true)}
...(canOpenSettings {/each}
? [
{ {#if canOpenSettings}
label: 'Settings', {@render actionRow('Settings', Settings, currentPath.startsWith('/settings'), onOpenSettings)}
icon: Settings, {/if}
active: currentPath.startsWith('/settings'), {@render actionRow('Logout', LogOut, false, onSignOut)}
onSelect: onOpenSettings, </div>
type: 'button' as const
}
]
: []),
{
label: 'Logout',
icon: LogOut,
onSelect: onSignOut,
type: 'button' as const
}
]}
/>
<div class="sidebar-meta-foot"> <div class="sidebar-meta-foot">
<div class="sidebar-meta-top"> <div class="sidebar-meta-top">
@@ -119,18 +203,28 @@
</aside> </aside>
<style> <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 { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.55rem; gap: 0.75rem;
padding: 1.1rem 0.85rem 0.85rem; padding: 1rem 0.8rem 0.85rem;
background: var(--panel); background: var(--sidebar-bg);
border-right: 1px solid var(--line); border-right: 1px solid var(--sidebar-border);
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; height: 100vh;
overflow-y: auto; overflow: hidden;
scrollbar-width: thin; }
.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 { .sidebar-body {
@@ -138,45 +232,301 @@
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; 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 { .brand-row {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 0.68rem; gap: 0.9rem;
padding: 0.2rem 0.35rem 0.95rem; padding: 0.15rem 0.35rem 0.95rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--sidebar-border);
} }
.brand { .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%; 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 { .rail-row:hover {
width: min(100%, 15.5rem); background: var(--sidebar-hover);
max-width: none; color: var(--sidebar-text-strong);
height: auto;
display: block;
object-fit: contain;
} }
.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 { .sidebar-meta {
margin-top: auto; margin-top: auto;
display: grid; display: grid;
gap: 0.7rem; gap: 0.75rem;
padding-top: 1rem; padding-top: 0.75rem;
border-top: 1px solid var(--sidebar-border);
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar-meta-foot { .sidebar-meta-foot {
display: grid; display: grid;
gap: 0.55rem; gap: 0.55rem;
padding: 0.8rem 0.55rem 0; padding: 0.7rem 0.5rem 0;
border-top: 1px solid var(--line); border-top: 1px solid var(--sidebar-border);
color: var(--muted); color: var(--sidebar-text-muted);
font-size: 0.76rem; font-size: 0.76rem;
} }
@@ -197,7 +547,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
color: var(--muted); color: var(--sidebar-text-muted);
white-space: nowrap; white-space: nowrap;
} }
@@ -209,7 +559,7 @@
.powered-by strong { .powered-by strong {
font-size: 0.76rem; font-size: 0.76rem;
font-weight: 600; font-weight: 600;
color: #5e6c64; color: var(--sidebar-text);
} }
.lean101-logo { .lean101-logo {
@@ -225,19 +575,18 @@
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
padding: 0.24rem 0.56rem; padding: 0.24rem 0.56rem;
border: 1px solid var(--line); border: 1px solid var(--sidebar-border);
border-radius: 999px; border-radius: 999px;
background: var(--panel-soft); background: color-mix(in srgb, var(--sidebar-text-strong) 4%, transparent);
color: #5e6c64; color: var(--sidebar-text);
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 600; font-weight: 600;
} }
.meta-label { .meta-label {
color: var(--muted); color: var(--sidebar-text-muted);
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
} }
</style> </style>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Settings } from 'lucide-svelte'; import { Settings } from 'lucide-svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte'; import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
import type { AppSession } from '$lib/session'; import type { AppSession } from '$lib/session';
import type { Crumb } from '$lib/navigation/client-navigation'; import type { Crumb } from '$lib/navigation/client-navigation';
@@ -36,6 +37,9 @@
<header class="topbar"> <header class="topbar">
<div class="topbar-start"> <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"> <div class="topbar-copy">
<nav class="breadcrumbs" aria-label="Breadcrumb"> <nav class="breadcrumbs" aria-label="Breadcrumb">
{#each breadcrumbs as crumb, index} {#each breadcrumbs as crumb, index}
@@ -60,6 +64,8 @@
{/if} {/if}
<div class="topbar-actions"> <div class="topbar-actions">
<ThemeToggle />
<div class="menu-wrap user-menu-wrap"> <div class="menu-wrap user-menu-wrap">
<button aria-expanded={userMenuOpen} class="user-trigger" type="button" onclick={onToggleUserMenu}> <button aria-expanded={userMenuOpen} class="user-trigger" type="button" onclick={onToggleUserMenu}>
<span class="user-avatar-wrap"> <span class="user-avatar-wrap">
@@ -127,8 +133,22 @@
.topbar-start { .topbar-start {
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 0.82rem; 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 { .topbar-copy h1 {
@@ -163,7 +183,7 @@
} }
.breadcrumb-sep { .breadcrumb-sep {
color: #b9c5be; color: var(--color-text-muted);
font-size: 0.78rem; font-size: 0.78rem;
} }
@@ -176,7 +196,7 @@
:global(.topbar-search) { :global(.topbar-search) {
width: 100%; width: 100%;
min-height: 2.75rem; 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 { .topbar-actions {
@@ -205,10 +225,10 @@
align-items: center; align-items: center;
gap: 0.72rem; gap: 0.72rem;
padding: 0.56rem 0.76rem; padding: 0.56rem 0.76rem;
border: 1px solid var(--line); border: 1px solid var(--color-border);
border-radius: 0.96rem; border-radius: 0.96rem;
background: var(--panel-soft); background: var(--panel-soft);
color: #304038; color: var(--color-text-primary);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
@@ -225,8 +245,8 @@
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
border-radius: 50%; border-radius: 50%;
background: var(--green-deep); background: var(--color-brand);
color: #fff; color: var(--color-on-brand);
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -240,16 +260,16 @@
width: 0.55rem; width: 0.55rem;
height: 0.55rem; height: 0.55rem;
border-radius: 999px; border-radius: 999px;
border: 1.5px solid var(--panel-soft); border: 1.5px solid var(--color-bg-surface);
background: #b4c0ba; background: var(--color-text-muted);
} }
.user-status-dot.live { .user-status-dot.live {
background: #4ade80; background: var(--color-success);
} }
.user-status-dot.idle { .user-status-dot.idle {
background: #c08b3d; background: var(--color-warning);
} }
.user-menu-avatar { .user-menu-avatar {
@@ -260,8 +280,8 @@
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
border-radius: 50%; border-radius: 50%;
background: var(--green-deep); background: var(--color-brand);
color: #fff; color: var(--color-on-brand);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -329,8 +349,8 @@
.chevron { .chevron {
width: 0.54rem; width: 0.54rem;
height: 0.54rem; height: 0.54rem;
border-right: 2px solid #7a8c82; border-right: 2px solid var(--color-text-muted);
border-bottom: 2px solid #7a8c82; border-bottom: 2px solid var(--color-text-muted);
transform: rotate(45deg); transform: rotate(45deg);
transition: transform 140ms ease; transition: transform 140ms ease;
} }
@@ -350,16 +370,15 @@
padding: 0.4rem; padding: 0.4rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.96rem; border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.98); background: var(--color-bg-elevated);
box-shadow: 0 4px 12px rgba(0,0,0,0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10px);
} }
.menu-panel a, .menu-panel a,
.menu-panel button { .menu-panel button {
padding: 0.72rem 0.78rem; padding: 0.72rem 0.78rem;
border-radius: 0.78rem; border-radius: 0.78rem;
color: #304038; color: var(--color-text-primary);
text-align: left; text-align: left;
background: transparent; background: transparent;
border: none; border: none;
@@ -397,6 +416,14 @@
padding: 0.72rem 1rem; padding: 0.72rem 1rem;
} }
.topbar-brand {
padding-right: 0.6rem;
}
.topbar-brand img {
height: 1.6rem;
}
.user-trigger { .user-trigger {
min-width: auto; min-width: auto;
width: 100%; width: 100%;
@@ -35,18 +35,18 @@
} }
.search-box:hover { .search-box:hover {
border-color: color-mix(in srgb, var(--color-brand) 22%, var(--line)); border-color: color-mix(in srgb, var(--color-brand) 24%, var(--line));
background: #fff; background: var(--color-bg-surface);
} }
.search-box:focus-visible { .search-box:focus-visible {
outline: none; outline: none;
border-color: var(--color-brand); 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 { .search-placeholder {
color: #93a098; color: var(--color-text-muted);
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -58,7 +58,7 @@
display: inline-block; display: inline-block;
width: 0.82rem; width: 0.82rem;
height: 0.82rem; height: 0.82rem;
border: 2px solid #98a59d; border: 2px solid var(--color-text-muted);
border-radius: 999px; border-radius: 999px;
} }
@@ -70,7 +70,7 @@
width: 0.42rem; width: 0.42rem;
height: 2px; height: 2px;
border-radius: 999px; border-radius: 999px;
background: #98a59d; background: var(--color-text-muted);
transform: rotate(45deg); transform: rotate(45deg);
} }
@@ -79,7 +79,7 @@
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 0.42rem; border-radius: 0.42rem;
color: var(--muted); color: var(--muted);
background: #fff; background: var(--color-bg-surface);
font-size: 0.76rem; font-size: 0.76rem;
} }
</style> </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 { import {
BadgeDollarSign,
Calculator, Calculator,
ClipboardPenLine, ClipboardPenLine,
Gauge, Gauge,
Layers,
LayoutDashboard, LayoutDashboard,
ShieldCheck, ShieldCheck,
TrendingUp TrendingUp
@@ -37,6 +39,23 @@ export type FooterLink = {
icon: ComponentType; 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 = { export type Crumb = {
label: string; label: string;
href?: string; href?: string;
@@ -58,9 +77,18 @@ export const mixCalculatorItem: NavItem = {
moduleKey: 'mix_calculator' 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 = { export const editorItem: NavItem = {
href: '/editor', href: '/editor',
label: 'Editor', label: 'Mix Editor',
shortLabel: 'ED', shortLabel: 'ED',
icon: ClipboardPenLine, icon: ClipboardPenLine,
moduleKey: 'products', moduleKey: 'products',
@@ -100,6 +128,7 @@ export const accessControlItem: NavItem = {
export const clientNavigationItems: NavItem[] = [ export const clientNavigationItems: NavItem[] = [
dashboardItem, dashboardItem,
mixCalculatorItem, mixCalculatorItem,
productCostingItem,
throughputItem, throughputItem,
editorItem, editorItem,
accessControlItem accessControlItem
@@ -108,9 +137,15 @@ export const clientNavigationItems: NavItem[] = [
export const footerLinks: FooterLink[] = []; export const footerLinks: FooterLink[] = [];
export const baseSearchItems: SearchItem[] = [ 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', href: '/editor',
label: 'Open Editor', label: 'Open Mix Editor',
description: 'Edit client, product, and mix naming from one table.', description: 'Edit client, product, and mix naming from one table.',
keywords: 'editor products mixes clients names bulk table phf horse manning' 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) { export function matchesRoute(href: string, pathname: string) {
return href === '/' ? pathname === '/' : pathname.startsWith(href); return href === '/' ? pathname === '/' : pathname.startsWith(href);
} }
@@ -185,8 +264,12 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
return [...crumbs, { label: 'Mix Calculator' }]; return [...crumbs, { label: 'Mix Calculator' }];
} }
if (pathname.startsWith('/product-costing')) {
return [...crumbs, { label: 'Product Costing' }];
}
if (pathname.startsWith('/editor')) { if (pathname.startsWith('/editor')) {
return [...crumbs, { label: 'Editor' }]; return [...crumbs, { label: 'Mix Editor' }];
} }
if (pathname.startsWith('/mixes')) { if (pathname.startsWith('/mixes')) {
@@ -202,6 +285,7 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
const sectionMap: Record<string, string> = { const sectionMap: Record<string, string> = {
'/raw-materials': 'Raw Materials', '/raw-materials': 'Raw Materials',
'/product-costing': 'Product Costing',
'/products': 'Products', '/products': 'Products',
'/scenarios': 'Scenarios', '/scenarios': 'Scenarios',
'/client-access': 'Client Access', '/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>; 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 = { export type EditorProductRow = {
id: number; id: number;
tenant_id: string; tenant_id: string;
@@ -382,6 +449,39 @@ export type DashboardSummary = {
mix_cost_per_kg: number[]; mix_cost_per_kg: number[];
product_finished_delivered: 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 = { 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']); 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) { export function canOpenEditor(session: AppSession | null | undefined) {
if (!session) { if (!session) {
return false; return false;
@@ -140,6 +144,11 @@ export const routeAccessRules: RouteAccessRule[] = [
matches: (pathname) => hasPathPrefix(pathname, '/raw-materials') matches: (pathname) => hasPathPrefix(pathname, '/raw-materials')
}, },
{ path: '/mixes', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/mixes') }, { 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: '/products', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/products') },
{ path: '/editor', roles: ['admin'], matches: (pathname) => hasPathPrefix(pathname, '/editor') }, { path: '/editor', roles: ['admin'], matches: (pathname) => hasPathPrefix(pathname, '/editor') },
{ path: '/reporting', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/reporting') }, { 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('/mix-calculator')) return canOpenMixCalculator(session);
if (pathname.startsWith('/raw-materials')) return canOpenRawMaterials(session); if (pathname.startsWith('/raw-materials')) return canOpenRawMaterials(session);
if (pathname.startsWith('/mixes')) return canOpenMixMaster(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('/products')) return canOpenProducts(session);
if (pathname.startsWith('/editor')) return canOpenEditor(session); if (pathname.startsWith('/editor')) return canOpenEditor(session);
if (pathname.startsWith('/scenarios')) return canOpenScenarios(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-500.css';
import '@fontsource/inter/latin-600.css'; import '@fontsource/inter/latin-600.css';
import '@fontsource/inter/latin-700.css'; import '@fontsource/inter/latin-700.css';
import '$lib/styles/theme.css';
import '$lib/theme';
import { beforeNavigate, afterNavigate } from '$app/navigation'; import { beforeNavigate, afterNavigate } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import AdminShell from '$lib/components/AdminShell.svelte'; import AdminShell from '$lib/components/AdminShell.svelte';
@@ -51,19 +53,3 @@
<Toast /> <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 type { DashboardSummary } from '$lib/types';
import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access'; import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access';
import packageInfo from '../../package.json'; 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'; import { tick } from 'svelte';
type Segment = { type Segment = {
@@ -115,6 +126,13 @@
return `$${value.toFixed(digits)}`; 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) { function formatDate(value: string | null | undefined) {
if (!value) { if (!value) {
return 'No date'; return 'No date';
@@ -314,6 +332,7 @@
const trendArea = $derived(areaPath(trendSeries)); const trendArea = $derived(areaPath(trendSeries));
const trendFocus = $derived(focusMarker(trendSeries)); const trendFocus = $derived(focusMarker(trendSeries));
const topProducts = $derived(summary?.products?.top_products ?? []); const topProducts = $derived(summary?.products?.top_products ?? []);
const operations = $derived(summary?.operations ?? null);
const focusCards = $derived(buildFocusCards(summary)); const focusCards = $derived(buildFocusCards(summary));
const loading = $derived(summary === null); const loading = $derived(summary === null);
const greeting = $derived(timeOfDay()); const greeting = $derived(timeOfDay());
@@ -465,7 +484,7 @@
</div> </div>
<div class="intro-actions"> <div class="intro-actions">
{#if canOpenEditor($clientSession)} {#if canOpenEditor($clientSession)}
<a class="primary-button" href="/editor">Open Editor</a> <a class="primary-button" href="/editor">Open Mix Editor</a>
{/if} {/if}
</div> </div>
</section> </section>
@@ -610,6 +629,146 @@
</div> </div>
</section> </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"> <section class="analysis-grid">
<article class="panel-card chart-card"> <article class="panel-card chart-card">
<div class="card-toolbar"> <div class="card-toolbar">
@@ -1094,6 +1253,7 @@
.workspace-banner, .workspace-banner,
.focus-row, .focus-row,
.dashboard-grid, .dashboard-grid,
.operations-report,
.analysis-grid, .analysis-grid,
.detail-grid { .detail-grid {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
@@ -1177,15 +1337,15 @@
.primary-button { .primary-button {
border: none; border: none;
color: #fff; color: var(--color-on-brand);
background: var(--color-brand); background: var(--color-brand);
box-shadow: none; box-shadow: none;
} }
.secondary-button { .secondary-button {
border: 1px solid var(--line-strong); border: 1px solid var(--color-border);
color: #304038; color: var(--color-text-primary);
background: #fff; background: var(--color-bg-surface);
} }
.secondary-button.compact { .secondary-button.compact {
@@ -1206,6 +1366,276 @@
padding: 1.2rem; 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 { .focus-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1249,15 +1679,15 @@
} }
.focus-card.positive { .focus-card.positive {
background: linear-gradient(180deg, #f6fbf7 0%, #edf8f0 100%); background: var(--color-success-tint);
} }
.focus-card.warning { .focus-card.warning {
background: linear-gradient(180deg, #fffaf3 0%, #fff3e3 100%); background: var(--color-warning-tint);
} }
.focus-card.neutral { .focus-card.neutral {
background: linear-gradient(180deg, #f7faf8 0%, #eff4f1 100%); background: var(--panel-soft);
} }
.focus-code { .focus-code {
@@ -1267,8 +1697,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 0.75rem; border-radius: 0.75rem;
color: #fff; color: var(--color-on-brand);
background: var(--green-deep); background: var(--color-brand);
font-size: 0.74rem; font-size: 0.74rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@@ -1369,8 +1799,9 @@
} }
.toggle-pill .active { .toggle-pill .active {
color: #fff; color: var(--color-text-primary);
background: var(--green-deep); background: var(--color-bg-surface);
border: 1px solid var(--color-border);
} }
.market-layout { .market-layout {
@@ -1410,7 +1841,11 @@
height: 8.5rem; height: 8.5rem;
flex-shrink: 0; flex-shrink: 0;
border-radius: 1.4rem; 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; overflow: hidden;
} }
@@ -1421,7 +1856,12 @@
width: 2.9rem; width: 2.9rem;
height: 2.9rem; height: 2.9rem;
border-radius: 999px; 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 { .field-stripe {
@@ -1430,7 +1870,7 @@
right: -10%; right: -10%;
height: 22%; height: 22%;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.18); background: color-mix(in srgb, var(--color-on-brand) 16%, transparent);
transform: rotate(-18deg); transform: rotate(-18deg);
} }
@@ -1864,10 +2304,15 @@
@media (max-width: 1120px) { @media (max-width: 1120px) {
.analysis-grid, .analysis-grid,
.detail-grid { .detail-grid,
.operations-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.operations-link {
justify-self: start;
}
.focus-grid { .focus-grid {
min-width: 0; min-width: 0;
} }
@@ -1889,6 +2334,10 @@
.focus-grid { .focus-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.operations-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -1935,10 +2384,15 @@
} }
.preview-facts, .preview-facts,
.operations-metrics,
.signin-form { .signin-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.operations-graphic {
width: 100%;
}
.field-emblem { .field-emblem {
width: 6.5rem; width: 6.5rem;
height: 6.5rem; height: 6.5rem;
+2 -1
View File
@@ -8,7 +8,8 @@ const EMPTY_SUMMARY: DashboardSummary = {
raw_materials: null, raw_materials: null,
mixes: null, mixes: null,
products: 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 // Streaming load: the route shell paints immediately and the dashboard fills
+203 -135
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api } from '$lib/api';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import type { EditorProductFormula, EditorProductIngredient, EditorProductRow, RawMaterial } from '$lib/types'; import type { EditorProductFormula, EditorProductIngredient, EditorProductRow, RawMaterial } from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte'; import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -345,94 +346,99 @@
}); });
</script> </script>
<section class="editor"> <AppSecondaryRailLayout>
<div class="status-band"> {#snippet rail()}
<div class="editor-status"> <div class="filter-rail" aria-label="Mix Editor filters">
<span> <p class="rail-label">Mix Editor</p>
<strong>Editor</strong>
<small>{visibleRows.length} products across {uniqueMixCount} mixes</small> <div class="rail-identity">
</span> <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>
<div class="filter-rail-body">
<label class="filter-search">
<span>Search</span>
<div class="search-input">
<Search size={17} strokeWidth={2.2} />
<input bind:value={query} type="search" placeholder="Client, product, mix, item ID, unit" />
</div>
</label>
<div class="status-filter" role="group" aria-label="Status filter">
<span class="field-label">Status</span>
<div class="segmented-control">
<button type="button" class:active={visibilityFilter === 'visible'} aria-pressed={visibilityFilter === 'visible'} onclick={() => setVisibilityFilter('visible')}>Active</button>
<button type="button" class:active={visibilityFilter === 'hidden'} aria-pressed={visibilityFilter === 'hidden'} onclick={() => setVisibilityFilter('hidden')}>Inactive</button>
<button type="button" class:active={visibilityFilter === 'all'} aria-pressed={visibilityFilter === 'all'} onclick={() => setVisibilityFilter('all')}>All</button>
</div>
</div>
<label>
<span>Client</span>
<select bind:value={clientFilter}>
<option value="all">All clients</option>
{#each clientOptions as client}
<option value={client}>{client}</option>
{/each}
</select>
</label>
<label>
<span>Product</span>
<input bind:value={productFilter} type="search" placeholder="Product name" />
</label>
<label>
<span>Pack</span>
<select bind:value={packFilter}>
<option value="all">All packs</option>
{#each packOptions as option}
<option value={option}>{option}</option>
{/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> </div>
{/snippet}
<dl class="facts"> <section class="editor">
<div class="fact"> <div class="status-band">
<dt>Products</dt> <div class="editor-status">
<dd>{visibleRows.length}</dd> <span>
</div> <strong>Mix Editor</strong>
<div class="fact"> <small>{visibleRows.length} products across {uniqueMixCount} mixes</small>
<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> </span>
<strong>{visibleRows.length} matching products</strong>
</div> </div>
{#if filtersActive} <dl class="facts">
<button type="button" class="clear-button" onclick={clearFilters}> <div class="fact">
<X size={16} strokeWidth={2.4} /> Clear filters <dt>Products</dt>
</button> <dd>{visibleRows.length}</dd>
{/if} </div>
<div class="fact">
<dt>Mixes</dt>
<dd>{uniqueMixCount}</dd>
</div>
<div class="fact">
<dt>Unsaved</dt>
<dd>{dirtyCount}</dd>
</div>
</dl>
</div> </div>
<div class="filter-grid"> <div class="pagination-bar" aria-label="Product table pagination">
<label class="filter-search">
<span>Search</span>
<div class="search-input">
<Search size={17} strokeWidth={2.2} />
<input bind:value={query} type="search" placeholder="Client, product, mix, item ID, unit" />
</div>
</label>
<div class="status-filter" role="group" aria-label="Status filter">
<span class="field-label">Status</span>
<div class="segmented-control">
<button type="button" class:active={visibilityFilter === 'visible'} aria-pressed={visibilityFilter === 'visible'} onclick={() => setVisibilityFilter('visible')}>Active</button>
<button type="button" class:active={visibilityFilter === 'hidden'} aria-pressed={visibilityFilter === 'hidden'} onclick={() => setVisibilityFilter('hidden')}>Inactive</button>
<button type="button" class:active={visibilityFilter === 'all'} aria-pressed={visibilityFilter === 'all'} onclick={() => setVisibilityFilter('all')}>All</button>
</div>
</div>
<label>
<span>Client</span>
<select bind:value={clientFilter}>
<option value="all">All clients</option>
{#each clientOptions as client}
<option value={client}>{client}</option>
{/each}
</select>
</label>
<label>
<span>Product</span>
<input bind:value={productFilter} type="search" placeholder="Product name" />
</label>
<label>
<span>Pack</span>
<select bind:value={packFilter}>
<option value="all">All packs</option>
{#each packOptions as option}
<option value={option}>{option}</option>
{/each}
</select>
</label>
</div>
</section>
<div class="pagination-bar" aria-label="Product table pagination">
<span>{pageStart}-{pageEnd} of {visibleRows.length}</span> <span>{pageStart}-{pageEnd} of {visibleRows.length}</span>
<label class="page-size"> <label class="page-size">
<span>Rows</span> <span>Rows</span>
@@ -452,9 +458,9 @@
<ChevronRight size={16} strokeWidth={2.4} /> <ChevronRight size={16} strokeWidth={2.4} />
</button> </button>
</div> </div>
</div> </div>
<div class="log"> <div class="log">
<div class="log-head" aria-hidden="true"> <div class="log-head" aria-hidden="true">
<span>Client</span> <span>Client</span>
<span>ID</span> <span>ID</span>
@@ -576,15 +582,104 @@
<button type="button" class="clear-button" onclick={clearFilters}>Clear filters</button> <button type="button" class="clear-button" onclick={clearFilters}>Clear filters</button>
</div> </div>
{/each} {/each}
</div> </div>
</section> </section>
</AppSecondaryRailLayout>
<style> <style>
.editor { .editor {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.9rem;
padding: 0.5rem 0 2rem; 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 { .status-band {
@@ -706,39 +801,6 @@
padding-inline: 0.2rem; 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 { .field-label {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -748,13 +810,6 @@
font-weight: 700; 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 { .pagination-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1130,8 +1185,7 @@
justify-content: flex-start; justify-content: flex-start;
} }
.ingredient-grid, .ingredient-grid {
.filter-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1140,14 +1194,28 @@
} }
} }
@media (max-width: 760px) { @media (max-width: 980px) {
.filter-head { .filter-rail {
align-items: stretch; position: static;
flex-direction: column; min-height: auto;
height: auto;
border-right: none;
overflow: visible;
} }
.filter-head .clear-button { .filter-rail-body {
width: 100%; 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 { .facts {
+11 -6
View File
@@ -11,6 +11,7 @@ const apiMocks = vi.hoisted(() => ({
productCosts: vi.fn(), productCosts: vi.fn(),
scenarios: vi.fn(), scenarios: vi.fn(),
dataQuality: vi.fn(), dataQuality: vi.fn(),
dashboardSummary: vi.fn(),
clientAccess: vi.fn(), clientAccess: vi.fn(),
clientAccessExport: vi.fn() clientAccessExport: vi.fn()
})); }));
@@ -61,6 +62,13 @@ describe('route loaders use the SvelteKit fetch argument', () => {
apiMocks.productCosts.mockResolvedValue([{ id: 4 }]); apiMocks.productCosts.mockResolvedValue([{ id: 4 }]);
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]); apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
apiMocks.dataQuality.mockResolvedValue([{ id: 6 }]); 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.clientAccess.mockResolvedValue([{ id: 7 }]);
apiMocks.clientAccessExport.mockResolvedValue({ apiMocks.clientAccessExport.mockResolvedValue({
generated_at: '', generated_at: '',
@@ -74,13 +82,10 @@ describe('route loaders use the SvelteKit fetch argument', () => {
}); });
it('passes fetch through the home page loader', async () => { 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.dashboardSummary).toHaveBeenCalledWith(fetcher);
expect(apiMocks.mixes).toHaveBeenCalledWith(fetcher);
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
expect(apiMocks.scenarios).toHaveBeenCalledWith(fetcher);
expect(apiMocks.dataQuality).toHaveBeenCalledWith(fetcher);
}); });
it('passes fetch through the raw materials loader', async () => { it('passes fetch through the raw materials loader', async () => {
@@ -63,7 +63,7 @@
<thead> <thead>
<tr> <tr>
<th>Session</th> <th>Session</th>
<th>Client / Product</th> <th>Client / Mix</th>
<th>Batch</th> <th>Batch</th>
<th>Bags</th> <th>Bags</th>
<th>Prepared by</th> <th>Prepared by</th>
@@ -78,7 +78,7 @@
<strong>{session.session_number}</strong> <strong>{session.session_number}</strong>
<span>{session.mix_name}</span> <span>{session.mix_name}</span>
</td> </td>
<td data-label="Client / Product"> <td data-label="Client / Mix">
<strong>{session.product_name}</strong> <strong>{session.product_name}</strong>
<span>{session.client_name}</span> <span>{session.client_name}</span>
</td> </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'; } from '$lib/types';
import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte'; import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte';
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte'; import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
import { toNum } from '$lib/format';
let { data } = $props<{ data: { products: ThroughputProduct[] } }>(); let { data } = $props<{ data: { products: ThroughputProduct[] } }>();
const products = $derived(data.products ?? []); 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() { function resetExceptDateAndStaff() {
productId = ''; productId = '';
bagSize = ''; bagSize = '';