v0.1.12
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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,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
@@ -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.
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,49 +36,89 @@ 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
|
||||||
@@ -79,3 +126,6 @@ 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
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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": {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
+39
-46
@@ -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,24 +128,18 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory GET cache with TTL + in-flight de-duplication. The cache key
|
// In-memory GET cache with TTL + in-flight de-duplication. The cache key
|
||||||
@@ -172,13 +158,12 @@ function makeCacheKey(path: string, auth: AuthMode) {
|
|||||||
|
|
||||||
async function cachedFetchJson<T>(
|
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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
if (pdfUrl) {
|
||||||
|
URL.revokeObjectURL(pdfUrl);
|
||||||
|
pdfUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPdf() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
revokePdfUrl();
|
||||||
|
|
||||||
|
try {
|
||||||
const blob = await api.mixCalculatorSessionPdf(session.id);
|
const blob = await api.mixCalculatorSessionPdf(session.id);
|
||||||
const url = URL.createObjectURL(blob);
|
pdfUrl = URL.createObjectURL(blob);
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
printAfterLoad = autoPrint;
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
} 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);
|
||||||
|
printPdfUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printCurrent() {
|
||||||
|
if (!preview) {
|
||||||
|
toast.error('Calculate the mix before printing.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
toast.dismiss(tid);
|
|
||||||
toast.error(formError || 'Complete the mix details first.');
|
toast.error(formError || 'Complete the mix details first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await api.previewMixCalculatorPdf(payload);
|
const tid = toast.loading('Preparing print...');
|
||||||
const url = URL.createObjectURL(blob);
|
try {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
const blob = initialSession ? await api.mixCalculatorSessionPdf(initialSession.id) : await api.previewMixCalculatorPdf(payload);
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
revokePrintPdfUrl();
|
||||||
|
printAfterPdfLoad = true;
|
||||||
|
printPdfUrl = URL.createObjectURL(blob);
|
||||||
toast.dismiss(tid);
|
toast.dismiss(tid);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.dismiss(tid);
|
toast.dismiss(tid);
|
||||||
toast.error(error instanceof Error ? error.message : 'Unable to open the styled PDF.');
|
toast.error(error instanceof Error ? error.message : 'Unable to prepare the PDF for printing.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printLoadedPdf() {
|
||||||
|
printFrame?.contentWindow?.focus();
|
||||||
|
printFrame?.contentWindow?.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrintFrameLoad() {
|
||||||
|
if (printAfterPdfLoad) {
|
||||||
|
printAfterPdfLoad = false;
|
||||||
|
requestAnimationFrame(printLoadedPdf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
revokePrintPdfUrl();
|
||||||
|
});
|
||||||
</script>
|
</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,
|
|
||||||
icon: item.icon
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,49 +346,22 @@
|
|||||||
});
|
});
|
||||||
</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>
|
||||||
|
|
||||||
<dl class="facts">
|
<div class="filter-rail-body">
|
||||||
<div class="fact">
|
|
||||||
<dt>Products</dt>
|
|
||||||
<dd>{visibleRows.length}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="fact">
|
|
||||||
<dt>Mixes</dt>
|
|
||||||
<dd>{uniqueMixCount}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="fact">
|
|
||||||
<dt>Unsaved</dt>
|
|
||||||
<dd>{dirtyCount}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="filters" aria-label="Editor filters">
|
|
||||||
<div class="filter-head">
|
|
||||||
<div>
|
|
||||||
<span class="section-label">
|
|
||||||
<ListFilter size={15} strokeWidth={2.2} />
|
|
||||||
Filters
|
|
||||||
</span>
|
|
||||||
<strong>{visibleRows.length} matching products</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if filtersActive}
|
|
||||||
<button type="button" class="clear-button" onclick={clearFilters}>
|
|
||||||
<X size={16} strokeWidth={2.4} /> Clear filters
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-grid">
|
|
||||||
<label class="filter-search">
|
<label class="filter-search">
|
||||||
<span>Search</span>
|
<span>Search</span>
|
||||||
<div class="search-input">
|
<div class="search-input">
|
||||||
@@ -429,8 +403,40 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{#if filtersActive}
|
||||||
|
<button type="button" class="clear-button rail-clear" onclick={clearFilters}>
|
||||||
|
<X size={16} strokeWidth={2.4} /> Clear filters
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<section class="editor">
|
||||||
|
<div class="status-band">
|
||||||
|
<div class="editor-status">
|
||||||
|
<span>
|
||||||
|
<strong>Mix Editor</strong>
|
||||||
|
<small>{visibleRows.length} products across {uniqueMixCount} mixes</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="facts">
|
||||||
|
<div class="fact">
|
||||||
|
<dt>Products</dt>
|
||||||
|
<dd>{visibleRows.length}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<dt>Mixes</dt>
|
||||||
|
<dd>{uniqueMixCount}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<dt>Unsaved</dt>
|
||||||
|
<dd>{dirtyCount}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="pagination-bar" aria-label="Product table pagination">
|
<div class="pagination-bar" aria-label="Product table pagination">
|
||||||
<span>{pageStart}-{pageEnd} of {visibleRows.length}</span>
|
<span>{pageStart}-{pageEnd} of {visibleRows.length}</span>
|
||||||
@@ -578,13 +584,102 @@
|
|||||||
{/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 +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 = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user