From 39a4eb5213a06f01f99a391e2f5f2bbd71466186 Mon Sep 17 00:00:00 2001 From: Spencer McGuire Date: Fri, 16 Oct 2026 17:13:31 -0600 Subject: [PATCH] Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board (waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag. Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays blocked until every line is pulled or short and every pulled line has a heat. Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind, optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events table (migration 0005) shown on the Admin Storage tab. Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed. Co-Authored-By: Claude Fable 5.1 --- .env.example | 9 + CHANGELOG.md | 11 + README.md | 7 +- deploy/compose.yaml | 31 + deploy/env/prod.env.example | 19 + deploy/env/test.env.example | 19 + docs/DEPLOY.md | 33 + docs/PLAN.md | 8 + docs/POLICY.md | 102 ++- docs/SCHEMA.md | 34 +- docs/SECURITY.md | 28 +- frontend/src/api/packets.ts | 18 + frontend/src/api/storage.ts | 10 + frontend/src/api/types.ts | 154 +++- frontend/src/api/warehouse.ts | 22 + frontend/src/components/Card.test.tsx | 36 + frontend/src/components/Card.tsx | 7 +- frontend/src/components/FindBar.tsx | 27 +- frontend/src/lib/format.ts | 13 + frontend/src/packet/PacketPage.tsx | 123 ++- frontend/src/packet/tabs.tsx | 69 +- frontend/src/screens/ScreenRoute.tsx | 2 + frontend/src/screens/admin/AdminPage.tsx | 5 +- .../src/screens/admin/StorageTab.test.tsx | 61 ++ frontend/src/screens/admin/StorageTab.tsx | 141 ++++ .../src/screens/warehouse/ShortsTable.tsx | 88 ++ .../screens/warehouse/WarehouseHome.test.tsx | 249 ++++++ .../src/screens/warehouse/WarehouseHome.tsx | 139 ++++ frontend/src/screens/warehouse/WhBoard.tsx | 227 +++++ frontend/src/screens/warehouse/boards.ts | 33 + frontend/src/theme/theme.css | 54 ++ frontend/src/warehouse/HoldSheet.tsx | 91 ++ frontend/src/warehouse/MtrViewer.tsx | 29 + frontend/src/warehouse/PullTable.test.tsx | 278 +++++++ frontend/src/warehouse/PullTable.tsx | 376 +++++++++ frontend/src/warehouse/ShortSheet.tsx | 208 +++++ frontend/src/warehouse/stages.test.ts | 70 ++ frontend/src/warehouse/stages.ts | 208 +++++ frontend/src/warehouse/useWhMove.tsx | 99 +++ server/packetstation/api/admin.py | 105 ++- server/packetstation/api/assets.py | 18 +- server/packetstation/api/packet_children.py | 141 +++- server/packetstation/api/warehouse.py | 185 +++++ server/packetstation/doctor.py | 25 +- server/packetstation/domain/assets.py | 372 ++++++++- server/packetstation/domain/packets.py | 71 +- server/packetstation/domain/pipeline.py | 38 +- server/packetstation/domain/scan.py | 130 +++ server/packetstation/main.py | 2 + .../migrations/0005_warehouse.sql | 24 + server/packetstation/policy/actions.py | 4 +- server/packetstation/policy/preconditions.py | 9 + server/packetstation/policy/principal.py | 1 + server/packetstation/settings.py | 11 + .../packetstation/worker/jobs/housekeeping.py | 14 +- tests/e2e/tests/visual-feed.spec.ts | 5 +- tests/e2e/tests/warehouse.spec.ts | 199 +++++ tests/e2e/tests/whhelpers.ts | 202 +++++ tests/server/fakeclam.py | 168 ++++ tests/server/test_packet_children.py | 27 +- tests/server/test_policy_matrix.py | 1 + tests/server/test_policy_warehouse.py | 176 ++++ tests/server/test_schema.py | 32 + tests/server/test_upload_pipeline.py | 465 +++++++++++ tests/server/test_warehouse_api.py | 780 ++++++++++++++++++ 65 files changed, 6213 insertions(+), 130 deletions(-) create mode 100644 frontend/src/api/storage.ts create mode 100644 frontend/src/api/warehouse.ts create mode 100644 frontend/src/components/Card.test.tsx create mode 100644 frontend/src/screens/admin/StorageTab.test.tsx create mode 100644 frontend/src/screens/admin/StorageTab.tsx create mode 100644 frontend/src/screens/warehouse/ShortsTable.tsx create mode 100644 frontend/src/screens/warehouse/WarehouseHome.test.tsx create mode 100644 frontend/src/screens/warehouse/WarehouseHome.tsx create mode 100644 frontend/src/screens/warehouse/WhBoard.tsx create mode 100644 frontend/src/screens/warehouse/boards.ts create mode 100644 frontend/src/warehouse/HoldSheet.tsx create mode 100644 frontend/src/warehouse/MtrViewer.tsx create mode 100644 frontend/src/warehouse/PullTable.test.tsx create mode 100644 frontend/src/warehouse/PullTable.tsx create mode 100644 frontend/src/warehouse/ShortSheet.tsx create mode 100644 frontend/src/warehouse/stages.test.ts create mode 100644 frontend/src/warehouse/stages.ts create mode 100644 frontend/src/warehouse/useWhMove.tsx create mode 100644 server/packetstation/api/warehouse.py create mode 100644 server/packetstation/domain/scan.py create mode 100644 server/packetstation/migrations/0005_warehouse.sql create mode 100644 tests/e2e/tests/warehouse.spec.ts create mode 100644 tests/e2e/tests/whhelpers.ts create mode 100644 tests/server/fakeclam.py create mode 100644 tests/server/test_policy_warehouse.py create mode 100644 tests/server/test_upload_pipeline.py create mode 100644 tests/server/test_warehouse_api.py diff --git a/.env.example b/.env.example index d869a22..b8dda2d 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,12 @@ # DRAWING_SOURCE=mfiles mfiles | fixture # FIXTURE_DIR=/fixtures where the fixture sources read feed..json, drawings..json and state # fixture reads synthetic files from tests/fixtures/generator.py. STACK=prod refuses it. +# +# Uploads and storage (phase 4). See deploy/env/*.env.example for the comments: +# UPLOAD_MAX_BYTES=26214400 drawings, MTRs and documents (25 MB) +# UPLOAD_MAX_BYTES_BOM=5242880 BOM uploads, XLSX or CSV (5 MB) +# ASSETS_QUOTA_WARN_PCT=85 the storage alert threshold, percent +# ASSETS_QUOTA_BYTES=0 0 measures the assets volume +# CLAMAV_HOST= empty: no virus scan. Set it and start the stack with --profile clamav +# CLAMAV_PORT=3310 +# CLAMAV_TIMEOUT_SECONDS=30 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2cc13..05bf858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project are recorded here. The format follows [Keep ### Added +- Phase 4: the Warehouse. The Warehouse home with the numbers (Waiting to pull, Being pulled, On hold, Ready for production, Open shorts, Released last 24 h), the Open shorts table with Receive, the find bar, and the task board: Waiting to pull, Being pulled, On hold, Pulled, ready for production, and Released to shop (3 days) built from the release transitions. Cards are grouped by department, oldest first by the day they were sent then VISUAL's release date, and move by button (Start pulling, Hold, Mark pulled with a warning when lines remain, Resume pulling, Back to pulling, Release…) or by dragging; a drop on the released column is refused. Hold asks for one of five reasons and a note ("Other" needs the note). Every role sees the real stage on a warehouse packet: the pill reads WH · waiting, being pulled, on hold or pulled, with who, how long and the hold reason under it. +- The pull table on the Materials tab for the warehouse: Pulled (the first tick starts the pulling stage), issued quantity, location, heat number typed or scanned with Enter moving to the next line, Short (record, edit, Not short after all) with the seven reasons, quantity, PO or requisition, ETA and note, receive with quantity, heat and note as an append-only receipt, N/A for parts with no traceability, and an MTR (PDF, PNG or JPG) per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays disabled with the server's reasons until every line not N/A is pulled or on an open short and every pulled line has a heat or is N/A or a sub-assembly. +- `GET /api/warehouse/board` (numbers, the five columns, the open shorts). Board cards carry the warehouse stage with who and when, the sent date, VISUAL's release date and the pull counts. +- The server validates the heat number pattern, the hold reasons and the short reasons, and writes the packet history as the old build did: "Heat recorded · item 10", "Short recorded · item 10", "Short received · item 10", "Warehouse: Put on hold", "Released to supervisor with 1 short". +- Receiving a short on a packet the warehouse no longer holds stays a warehouse right; the department that holds the packet can receive too, through custody. +- Uploads are typed by content per kind: drawings are PDF, MTRs and documents PDF, PNG or JPG (25 MB), hand BOMs XLSX or CSV (5 MB). XLSX is checked as a real workbook, CSV as clean text. Names are sanitised, files stored by id, served with Content-Disposition (attachment for spreadsheets) and nosniff. +- Optional ClamAV: a `clamav` service in compose behind the `clamav` profile. When `CLAMAV_HOST` is set every upload and every drawing the worker downloads is scanned before it is accepted; a hit is refused and audited, and an unreachable scanner refuses uploads. +- A storage quota alert: at 85 percent of the assets volume (configurable) the app raises a notification event, shown on the new Admin Storage tab with the usage, the ClamAV status and an Acknowledge button. Migration 0005 adds the `notification_events` table that phase 9 builds the delivery on. +- Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat pattern, upload sniffing and limits per kind, the ClamAV path with a fake scanner, the quota alert; frontend tests for the board and the pull table; Playwright scenarios for the warehouse board and the pull flow against the CI stack. +- docs/SCHEMA.md, POLICY.md, SECURITY.md (D5 closed) and DEPLOY.md updated. + - Phase 3: the VISUAL feed and M-Files drawings run in the worker. Migration 0004 with the VISUAL cache (batches, rows, materials, orders and order links, labor, per-batch change rows), the go-live ignored list, the resource map seeded from the old build's map (fold and ignore lines included), drawing lookups, job requests, job runs, job schedule and the worker heartbeat, plus the order-derived columns with provenance on projects and units. The work order key on a packet now includes the operation sequence, so one packet can hold two operations of the same work order. - The real worker: APScheduler with a settings-driven trigger that re-reads the feed settings on every fire, shop-hours and off-hours intervals, pause, LISTEN on `ps_settings` and `ps_job_request`, one advisory lock per job, `job_runs` rows with counts and redacted errors, a heartbeat row and file, the stack guard before every VISUAL read, and a housekeeping job that prunes the caches at 90 days and the runs at 180. - The `visual-feed` job: five reads through VisualKit (released work orders in the window with `until` always passed, every tracked work order by key with no status or date filter, operations and part specs and parts and materials with `mat_known` per leg, order links by base and by project, labor tickets at most every 30 minutes), COPY and MERGE into the caches with change rows, the suspect-batch guard, and the apply in one transaction: candidates by the old rules (one packet is drawing plus lot plus operation; fold and ignore; project from the base prefix; unit is project plus the three-digit lot), the go-live boundary with the ignored list and pull-in, matching on the VISUAL key across live and removed packets (a removed packet is never recreated), new drafts, drafts updated in place, change orders opened, re-aimed, withdrawn or followed on for packets past draft (minor when only target hours or the want date changed), left-behind drafts retired, work order gone after two misses, closed and cancelled observed per leg, projects and units created with customer, PO and dates from the order links and the several-open-orders rule in one function with the provenance stored, auto-release of clean drafts up to 25 per run, audit rows and NOTIFY. diff --git a/README.md b/README.md index 842159f..0d151d9 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo ## Status -**Rebuild in progress. Phase 3 done: the VISUAL feed and M-Files drawing lookups run in the worker.** +**Rebuild in progress. Phase 4 done: the Warehouse screen, the pull table, shorts and MTRs, and the hardened upload pipeline.** -People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. The worker reads released work orders from VISUAL on a schedule, makes and updates drafts, opens change orders on packets already in the shop, looks drawings up in M-Files and attaches them at the rev VISUAL calls. Clerical sees it all on the From VISUAL panel and in the Flagged and Changes to approve columns; Admin sets the feed up on the Feed tab. Every packet write goes through the server policy. The warehouse, shop and Q.C. screens are still placeholders; their API exists. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:. +People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. The worker reads released work orders from VISUAL on a schedule, makes and updates drafts, opens change orders on packets already in the shop, looks drawings up in M-Files and attaches them at the rev VISUAL calls. Clerical sees it all on the From VISUAL panel and in the Flagged and Changes to approve columns; Admin sets the feed up on the Feed tab. Warehouse works its task board (waiting, being pulled, on hold, pulled, released), pulls parts line by line with heat numbers, shorts, receipts and MTRs, and releases to the supervisor once every line is pulled or short and every pulled line has a heat. Every packet write goes through the server policy. The shop and Q.C. screens are still placeholders; their API exists. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:. | Area | Exists yet? | |---|---| @@ -17,7 +17,8 @@ People can sign in, set up projects and units, make hand drafts, attach a drawin | Sign-in (AD password, PIN on registered devices), sessions, lock screen, Account settings, Admin shell, audit, SSE | Yes (phase 1, verified against the Samba DC in the CI stack on docker-1) | | Schema, policy engine, packet API, projects and units, assets, Clerical screen, packet page | Yes (phase 2, verified against PostgreSQL and the Samba DC in the CI stack on docker-1) | | VISUAL feed, M-Files drawings, change orders from the feed, From VISUAL panel, Admin Feed tab and resource map | Yes (phase 3, run once against VISUAL DEV and the vault from a developer machine; fixture-driven in CI) | -| Warehouse, shop, Q.C., close and file, PDFs | API only (phase 2); screens in phases 4 to 7 | +| Warehouse screen, pull table, shorts and receipts, MTRs, release gate, upload kinds, optional ClamAV, storage alert | Yes (phase 4, verified in the CI stack on docker-1) | +| Shop, Q.C., close and file, PDFs | API only (phase 2); screens in phases 5 to 7 | | Project Management, Admin, notifications, backups | No (phases 8 and 9) | | Regression bench and go-live | No (phase 10) | diff --git a/deploy/compose.yaml b/deploy/compose.yaml index c83bb65..61c8712 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -10,6 +10,9 @@ # # No service publishes a port. NPM reaches the app over npm_default (compose.proxy.yaml). # PostgreSQL is only on the internal backend network. +# +# ClamAV is optional. Set CLAMAV_HOST=packetstation-clamav in the env file and add `--profile clamav` +# to every `docker compose` command of that stack. Without the profile the clamav service does not run. # The project name comes from the env file so prod and test can never share volumes by accident. # `docker compose -p` still wins when given. @@ -164,6 +167,33 @@ services: restart: unless-stopped logging: *logging + clamav: + # Optional virus scanner for uploads (profile clamav). clamd listens on 3310 on the backend network. + # Pinned to the 1.4 minor tag, not a digest: the tag follows the 1.4 patch releases. + # To pin a digest: docker buildx imagetools inspect clamav/clamav:1.4 and use the top-level "Digest:". + image: clamav/clamav:1.4 + profiles: ["clamav"] + environment: + CLAMAV_NO_MILTERD: "true" + volumes: + - clamav-db:/var/lib/clamav # Signatures. The first start downloads them (a few minutes). + networks: + backend: + aliases: + - packetstation-clamav # CLAMAV_HOST. Unique so it cannot collide with another stack. + egress: {} # freshclam needs the internet for signature updates. + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "clamdcheck.sh"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 10m # Room for the first signature download and load. + mem_limit: 2g + restart: unless-stopped + logging: *logging + networks: backend: internal: true # No route out. postgres lives only here. @@ -173,6 +203,7 @@ networks: volumes: pgdata: {} assets: {} + clamav-db: {} # Host files, root:10001 mode 0440, one value per file, no trailing carriage return. secrets: diff --git a/deploy/env/prod.env.example b/deploy/env/prod.env.example index 7c5aa62..f2c8650 100644 --- a/deploy/env/prod.env.example +++ b/deploy/env/prod.env.example @@ -69,6 +69,25 @@ ASSETS_DIR=/data/assets # Built frontend. Compose sets it too. STATIC_DIR=/app/static +# ---- Uploads and storage ---- +# Largest drawing, MTR or document upload, in bytes (25 MB). +UPLOAD_MAX_BYTES=26214400 +# Largest BOM upload (XLSX or CSV), in bytes (5 MB). +UPLOAD_MAX_BYTES_BOM=5242880 +# Open the storage alert when the assets store is this full, in percent (1 to 99). +ASSETS_QUOTA_WARN_PCT=85 +# 0 measures the assets volume. A byte count measures the stored files against that many bytes. +ASSETS_QUOTA_BYTES=0 + +# ---- ClamAV (optional virus scan of uploads) ---- +# Empty means no scan. When set, start the stack with --profile clamav on every docker compose command. +# An unreachable scanner then refuses uploads until it answers. +#CLAMAV_HOST=packetstation-clamav +# clamd port. +CLAMAV_PORT=3310 +# Seconds to wait for a scan. +CLAMAV_TIMEOUT_SECONDS=30 + # ---- LDAP (PESCO Active Directory; CI gets the Samba DC in phase 1) ---- # Both domain controllers, tried in order. LDAP_URL="ldaps://DC-02.PESCO.COM:636 ldaps://PDC-1.PESCO.COM:636" diff --git a/deploy/env/test.env.example b/deploy/env/test.env.example index d825912..c88a99c 100644 --- a/deploy/env/test.env.example +++ b/deploy/env/test.env.example @@ -69,6 +69,25 @@ ASSETS_DIR=/data/assets # Built frontend. Compose sets it too. STATIC_DIR=/app/static +# ---- Uploads and storage ---- +# Largest drawing, MTR or document upload, in bytes (25 MB). +UPLOAD_MAX_BYTES=26214400 +# Largest BOM upload (XLSX or CSV), in bytes (5 MB). +UPLOAD_MAX_BYTES_BOM=5242880 +# Open the storage alert when the assets store is this full, in percent (1 to 99). +ASSETS_QUOTA_WARN_PCT=85 +# 0 measures the assets volume. A byte count measures the stored files against that many bytes. +ASSETS_QUOTA_BYTES=0 + +# ---- ClamAV (optional virus scan of uploads) ---- +# Empty means no scan. When set, start the stack with --profile clamav on every docker compose command. +# An unreachable scanner then refuses uploads until it answers. +#CLAMAV_HOST=packetstation-clamav +# clamd port. +CLAMAV_PORT=3310 +# Seconds to wait for a scan. +CLAMAV_TIMEOUT_SECONDS=30 + # ---- LDAP (PESCO Active Directory; CI gets the Samba DC in phase 1) ---- # Both domain controllers, tried in order. LDAP_URL="ldaps://DC-02.PESCO.COM:636 ldaps://PDC-1.PESCO.COM:636" diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 04986c6..d9ee4da 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -33,6 +33,7 @@ Both stacks run from the same image as separate compose projects. - `migrate`: one-shot, runs `packetstation migrate` as the migrate role. - `app`: `packetstation app` (uvicorn). `backend` network plus egress plus `npm_default` through the proxy overlay. Assets volume, CA bundle bind, secrets, healthcheck. - `worker`: `packetstation worker`. Same image. Runs the VISUAL feed, the drawing lookup and housekeeping (see [FEED.md](FEED.md)). `backend` network plus egress (VISUAL, M-Files), no `npm_default`, no published port. Assets volume (fetched drawings land there), CA bundle bind, backup folder read-only (used from phase 9). Its healthcheck reads the heartbeat file. +- `clamav` (optional, profile `clamav`, phase 4): `clamav/clamav:1.4`, pinned by tag (with a comment in the compose file), not by digest. clamd on port 3310 for the app and the worker, reached by the network alias `packetstation-clamav` (as `postgres` has its own alias). `backend` network plus egress (freshclam downloads the signatures), no `npm_default`, no published port. Volume `clamav-db` at `/var/lib/clamav`, `mem_limit: 2g`, healthcheck `clamdcheck.sh`. Started only with `--profile clamav`. See "Virus scan (ClamAV)". - All services restart unless stopped, log to stdout (json-file), and have healthchecks. ## Secrets @@ -69,6 +70,8 @@ Key settings: - `APP_ALIAS` per stack. - Identity timing and limits, all optional with these defaults: `SESSION_IDLE_MINUTES=15`, `SESSION_LOCK_MINUTES=3`, `SESSION_HIDDEN_LOCK_SECONDS=60`, `SESSION_PASSWORD_HOURS=12`, `SESSION_PIN_HOURS=10`, `ROLES_REFRESH_MINUTES=15`, `SIGNIN_ACCOUNT_LIMIT=5`, `SIGNIN_ACCOUNT_WINDOW_MINUTES=5`, `SIGNIN_ACCOUNT_COOLDOWN_MINUTES=10`, `SIGNIN_IP_LIMIT=30`, `SIGNIN_IP_WINDOW_MINUTES=5`, `PIN_MAX_FAILURES=5`, `STEPUP_MAX_FAILURES=3`, `DEVICE_COOKIE_DAYS=365`, `PIN_MIN_DIGITS=6`, `PIN_MAX_DIGITS=12`. - Worker sources, all optional: `FEED_SOURCE` (`visualkit`, the default, or `fixture`), `DRAWING_SOURCE` (`mfiles`, the default, or `fixture`) and `FIXTURE_DIR` (default `/fixtures`). A fixture source reads files instead of VISUAL or M-Files (see [FEED.md](FEED.md), "Fixture sources"). `STACK=prod` refuses a fixture source at start. The test and production stacks leave all three unset. Only the CI env file sets them. +- Uploads and storage, all optional: `UPLOAD_MAX_BYTES` (default 25 MB, drawings, MTRs and documents), `UPLOAD_MAX_BYTES_BOM` (default 5 MB, hand BOMs), `ASSETS_QUOTA_WARN_PCT` (default 85, 1 to 99) and `ASSETS_QUOTA_BYTES` (default 0, which means the size of the assets volume; any other value is a byte budget for the stored files). See "Storage quota". +- Virus scan, all optional: `CLAMAV_HOST` (empty by default, which means no scan; `packetstation-clamav` for the profile service), `CLAMAV_PORT` (default 3310) and `CLAMAV_TIMEOUT_SECONDS` (default 30). The env examples carry `#CLAMAV_HOST=packetstation-clamav` commented out. See "Virus scan (ClamAV)". - `SITE_ID` (default `PESCO`) and `SITE_TZ` (default `America/Denver`): the VISUAL site in every call, and the time zone for shop hours and the schedule. - The session cookie is marked `Secure` when the request arrived over https (as reported by NPM through `X-Forwarded-Proto`). Behind the proxy that is always the case. @@ -89,6 +92,35 @@ Production (planned, phase 10): the same command with `-p packetstation` and the Images come from `gt.pescoinc.biz` by tag. CI tags each tested image `:sha-`; a release re-tags it `vX.Y.Z`. Never `latest`. A deploy is a new image with a short downtime window. +## Virus scan (ClamAV, phase 4) + +ClamAV is optional and off by default. It closes requirement D5. Turn it on in the test stack first, check it, then the production stack. + +1. Set `CLAMAV_HOST=packetstation-clamav` in the stack's env file (`/etc/packetstation/test/stack.env`). Leave `CLAMAV_PORT` and `CLAMAV_TIMEOUT_SECONDS` at their defaults. +2. Start the stack with the profile: + +```bash +docker compose -p packetstation-test --env-file /etc/packetstation/test/stack.env \ + -f deploy/compose.yaml -f deploy/compose.proxy.yaml --profile clamav up -d +``` + +3. Wait for `clamav` to turn healthy. On the first start the volume `packetstation-test_clamav-db` is empty, so freshclam downloads the full signature set (a few hundred MB) before clamd answers. That takes several minutes. Later starts reuse the volume and only fetch updates. freshclam needs egress to the ClamAV mirrors, which is why the service is on the `egress` network. +4. Check it: Admin, Storage tab, the ClamAV line reads "packetstation-clamav: reachable". `packetstation doctor` also has a `clamav` check (a PING to clamd; SKIP when `CLAMAV_HOST` is empty). Upload a small PDF to a draft and confirm it is stored. + +Every later `up` for that stack must carry `--profile clamav`. Without it the service is not started. + +Fail closed. While `CLAMAV_HOST` is set, an upload the scanner cannot check is refused with "The virus scanner is not reachable. Try again in a minute." (503 `scan_unavailable`). That covers clamd still loading signatures, stopped, or out of memory. The worker's drawing fetches are refused the same way and tried again later. A file with a signature hit is refused (422 `infected`), never stored, and audited as `asset.rejected` with the signature name. + +To turn it off: remove `CLAMAV_HOST` from the env file, recreate `app` and `worker` (`up -d` without the profile), then stop the service with `docker compose ... --profile clamav stop clamav`. The volume can stay for the next time. The CI overlay never runs ClamAV. + +clamd holds the signatures in memory, about 1.2 GB with the full set and more while it reloads them, so the service has a 2 GB limit. Check docker-1 has the headroom for both stacks before turning it on in production. + +## Storage quota (phase 4) + +Every stored file and the housekeeping job check the assets volume. With `ASSETS_QUOTA_BYTES=0` (the default) the check reads the volume's own used and total bytes; with a byte budget it adds up the stored files' sizes against it. At `ASSETS_QUOTA_WARN_PCT` percent (85 by default) the app opens one `notification_events` alert of kind `assets_quota` and audits `storage.quota_warning`. When usage drops back under, the alert is resolved. A failed check is logged and never fails an upload. + +The Admin page, Storage tab, shows the usage bar (used of total, percent, the threshold), the open quota alert with an Acknowledge button, the ClamAV line ("Not configured", or the host with reachable or not reachable) and every open alert. Phase 9 sends the alert by email. Until then, check the Storage tab. + ## Python packages and the Gitea index The Gitea PyPI index `https://gt.pescoinc.biz/api/packages/spencerm/pypi/simple/` serves only `visualkit`, `mfileskit` and their dependencies. @@ -148,6 +180,7 @@ It checks: - Assets volume and free space. Backup folder (planned, phase 9). - LDAP TLS bind on both controllers, reading `memberOf`, `employeeID` and `userAccountControl` as the service account, the 50-group count and one nested-group query. - SMTP STARTTLS with certificate verification. +- ClamAV (phase 4): clamd answers PING at `CLAMAV_HOST`. SKIP when no host is set. - tzdata. Use `--skip visual,mfiles,ldap,smtp` to run only the local checks. The network checks need the secrets and the CA bundle in place. diff --git a/docs/PLAN.md b/docs/PLAN.md index 15f8a55..9cecd92 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -237,6 +237,14 @@ Smaller questions from the design, with the default the build takes unless told 17. SET_DATABOOK_AT is writable by anyone in the old build, even when filed: default restrict to Clerical and Q.C. 18. CO verifications cannot be withdrawn; ITP line signatures can be undone by the same inspector or an Admin before the block is signed. +From phase 4 (2026-10-16), for Seth to veto: + +19. The release gate has two server rules: every line not N/A is pulled or on an open short, and every pulled line that issued pieces has a heat unless it is N/A or a sub-assembly. The old build enforced both on the screen only. Default: both on the server. +20. Hold and short reasons: the old build accepted any text. Default: closed sets on the server; "Other" on a hold needs a note. +21. Receiving a short on a packet the warehouse no longer holds: default it stays a warehouse right (WH_RECEIVE_SHORT, any status but filed), and the holder of the packet (department lead or supervisor, Q.C., Clerical) can receive too, through edit_bom. +22. Upload kinds and limits: default an MTR is PDF, PNG or JPG up to 25 MB, a hand BOM is XLSX or CSV up to 5 MB. ClamAV is optional and off unless CLAMAV_HOST is set; when set, an unreachable scanner refuses uploads (fail closed). +23. The quota alert: default a `notification_events` row (phase 9 builds the delivery on this table) at 85 percent of the assets volume. + ## Verification - Every phase: `pytest` server suite, `npm test` and `npm run build` for the frontend, `docker compose -f deploy/compose.ci.yml up` with the Samba DC and the Playwright scenarios for that phase, `packetstation doctor` inside the image. diff --git a/docs/POLICY.md b/docs/POLICY.md index 8da3607..8ceb0aa 100644 --- a/docs/POLICY.md +++ b/docs/POLICY.md @@ -79,7 +79,7 @@ Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, revi | send_draft_to_qc | CL | draft | qc | no | | heat_map_answered, drawing_attached | | | | wh_send_back | WH | warehouse | draft | no | | reason_given | | yes | | return_to_clerical | DEPT_LEAD | ready, only when wh_skipped | draft | no | | reason_given | | yes | -| wh_release | WH | warehouse | ready | no | yes | bom_all_pulled_or_short | | | +| wh_release | WH | warehouse | ready | no | yes | bom_all_pulled_or_short, heats_recorded | | | | send_back_to_warehouse | DEPT_LEAD | ready | warehouse | no | | reason_given | | yes | | assign | DEPT_LEAD | ready | assigned | no | | welder_set | | | | unassign | DEPT_LEAD | assigned | ready | no | | | | | @@ -101,6 +101,8 @@ Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, revi | edit_wos | CL | draft | | | | | | | | edit_bom | HOLDER | draft, warehouse, SHOP | | | | | | | | wh_receive_short | WH | any | | | | short_open | | | + +A holder of the packet who is not in the warehouse receives a short through edit_bom (see "Receive by custody"). | edit_heats | HOLDER | warehouse, SHOP | | | | | | | | edit_heat_table | HOLDER | warehouse, SHOP | | | | | | | | edit_welds | HOLDER | SHOP | | | | | | | @@ -141,6 +143,7 @@ Each precondition is a named predicate over the packet state and the request inp | heat_map_answered | heat_map is yes or no | Say whether this packet needs a heat map. | | drawing_attached | a drawing is attached | Attach the drawing first. | | bom_all_pulled_or_short | every line not marked n/a is pulled or has an open short | Pull every material line or mark it short first. | +| heats_recorded | no line needs a heat: every line that is not N/A, not a sub-assembly, is pulled and issued pieces has a heat | Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability. | | short_open | at least one BOM line has an open short (the old whCanReceive) | No part is short on this packet. | | welder_set (input) | the request gives station_id and welder_id | Pick a station and a welder. | | checklist_complete | every key of the department checklist is done | Finish the checklist first. | @@ -199,6 +202,101 @@ Every packet read returns: `can(principal, state, assume_it_reason=True) -> (list[Action], dict[Action, list[str]])`. For an admin the IT reason is assumed, so the list shows what the admin may do once they give one. `evaluate(principal, state, action)` returns the read-mode decision with `admin_bypass` set, so the screen knows to ask for the reason. System-only actions are never listed. CO_VERIFY needs a department in the request, so it is not listed; the change order list says which departments the person can verify. +## Warehouse (phase 4) + +### Stages + +The warehouse stage is `packet_wh`, separate from the packet status. SET_WH_STAGE (WH, status warehouse) moves it, compare-and-set on `expect_stage` (409 `stale_step` when it moved on). + +| From | To | +|---|---| +| incoming | pulling, hold | +| pulling | pulled, hold | +| hold | pulling | +| pulled | pulling | + +A move to the same stage, or one not in the table, is 409 `wh_transition`. A packet with no `packet_wh` row is at incoming. The first Pulled tick on a line while the stage is incoming or on hold starts pulling by itself (`auto` true). + +### Hold reasons + +A closed set, checked on the server: + +- Waiting on material (not in stock) +- Waiting on purchasing / PO +- Question for engineering or clerical +- Drawing / BOM problem +- Other + +Any other text is 400 `invalid_reason` "Pick one of the hold reasons." "Other" needs a note, else 400 `reason_required` "Say what it's waiting on." The note is cleaned and cut to 200 characters. + +### Short reasons + +A closed set, checked on the server: + +- Not in stock +- On order +- Back-ordered +- Damaged / rejected +- Wrong part received +- Needs cutting / prep +- Other + +Any other text is 400 `invalid_reason` "Pick one of the short reasons." A short has a quantity from 1 to the line quantity, a reason, an optional PO or requisition, an optional expected date, an optional note, and the optional heat of the pieces that were pulled (never on an N/A line). Opening, editing and clearing a short is edit_bom, so the holder decides. In practice that is the warehouse. + +### The release gate + +WH_RELEASE checks two preconditions, in this order: + +1. `bom_all_pulled_or_short`: every line not N/A is pulled or has an open short. +2. `heats_recorded`: no line needs a heat. A line needs a heat when it is not N/A, not a sub-assembly (`sub` is `Y`; its heat is on its own packet), is pulled, has issued more than 0 and has no heat. + +Both are state preconditions, so `blocked.wh_release` lists the failing message and the screen shows Release disabled with it. Heat numbers are checked against the old pattern: letters, numbers, space . / # _ - only, up to 40 characters, starting with a letter or a number. A bad heat is 422 `invalid_heat`. + +### Receive by custody + +Receiving short parts (`POST .../bom/{item}/short/receipts`) picks its action from the person: + +- A member of the warehouse (WH in `support`) receives through WH_RECEIVE_SHORT, in any status but filed, while the line has an open short. The warehouse does not need to hold the packet. +- Anyone else receives through EDIT_BOM, so only the holder may: the lead or supervisor of the department that has the packet, Q.C. at Q.C., Clerical in draft. +- Everyone else is refused by the policy (403 `forbidden`). + +The warehouse still cannot change any other field of a packet it no longer holds. EDIT_BOM stays the holder's. A receipt needs a heat unless the line is N/A, and its quantity is 1 to what is still open. + +### MTR files + +The MTR on a line must be an asset whose type is PDF, PNG or JPEG. Anything else, or an asset that is not there, is 422 `invalid_asset`. Clearing the MTR is allowed. + +### Packet log texts + +The `action` column of `packet_log` for each warehouse write. `{item}` is the BOM item number. + +| Write | action | note | +|---|---|---| +| Heat typed or scanned | `Heat recorded · item {item}` | the heat | +| Heat cleared | `Heat cleared · item {item}` | | +| Pulled ticked | `Pulled · item {item}` | | +| Pulled unticked | `Pulled undone · item {item}` | | +| N/A ticked | `Marked N/A · item {item}` | | +| N/A unticked | `Traceable again · item {item}` | | +| Issued only | `Issued qty changed · item {item}` | `{issued} of {qty}` | +| Location only | `Location set · item {item}` | the location | +| Several fields | `Line changed · item {item}` | | +| MTR attached | `MTR attached · item {item}` | | +| MTR removed | `MTR removed · item {item}` | | +| Short opened | `Short recorded · item {item}` | `{qty} of {need} short · {reason} · {ref} · ETA {eta}`, parts present only | +| Short edited | `Short updated · item {item}` | the same as recorded | +| Not short after all | `Short cleared · item {item}` | | +| Short received | `Short received · item {item}` | `{qty} pc · heat {heat} · {still} still short` or `... · short closed`, then ` · packet {status}` (lower case) and ` in {station}` when at a station | +| Stage incoming to pulling | `Warehouse: Started pulling` | | +| Stage to hold | `Warehouse: Put on hold` | `{reason} · {note}` | +| Stage pulling to pulled | `Warehouse: Marked pulled, ready for production` | | +| Stage hold to pulling | `Warehouse: Pulling resumed` | | +| Stage pulled to pulling | `Warehouse: Back to pulling` | | +| wh_release | `Released to supervisor`, or `Released to supervisor with {n} short` when shorts are open | `{heats} heats recorded`, plus ` · SHORT: item {item} ×{qty_open} ({reason}), ...` when shorts are open | +| wh_send_back | `Sent back to Clerical` | the reason | + +When a Pulled tick starts pulling by itself, there is no separate stage row in the log: the line's own row gets ` · started pulling` or ` · pulling resumed` on its note. Every write above also writes its audit row through the write pipeline, with `detail.item`, `wh_from` and `wh_to` as before, and `detail.qty` on short writes. + ## Worker writes (phase 3) The worker has no session. It writes through the same policy as a person, as the system principal `Principal(user_id=None, system="visual-feed")`. Each write locks the packet row (`FOR UPDATE SKIP LOCKED`; a row locked by someone else is left for the next run), builds the packet state, runs `policy.check()`, makes its change, bumps `version`, and writes a `packet_log` row (by nobody, by name "VISUAL feed", role `system`), an audit row (`packet.`, role `system`) and a change event. See [FEED.md](FEED.md) for when each write happens. @@ -256,6 +354,8 @@ The old policy was looser than the old screens in places. The rebuild follows th | Anyone could set the data book date, even on a filed packet. | SET_DATABOOK_AT is Clerical or Q.C. only (open question 17). It stays allowed on a filed packet. | The data book date is a Clerical and Q.C. record. | Yes | | Change-order verifications were stored in the packet document, with no rule against changing them later. | Verifications are insert-only and cannot be withdrawn (open question 18). | A signed verification is a record. | Yes | | The old policy did not look at the BOM when the warehouse released a packet. | WH_RELEASE needs every BOM line not marked n/a to be pulled or to have an open short. | A packet should reach the shop with its material, or with the shorts on record. | Yes | +| The old screen asked for a heat number on every pulled line before release, but the policy did not check it. | WH_RELEASE also needs a heat on every pulled line that issued pieces, unless the line is N/A or a sub-assembly (`heats_recorded`), checked on the server. | Traceability is the point of the pull. The screen alone could be skipped. | Yes | +| Hold reasons and short reasons were any text the screen sent. | Both are closed sets checked on the server. "Other" on a hold needs a note. | The board and the shorts table group and report on the reason, so it must be one of the list. | Yes | | A hand draft could leave Clerical with no drawing (the packet PDF upload stood in for it). | A hand draft needs a drawing attached before any send from Clerical. | There is no packet PDF upload in the rebuild. | Yes | | Attaching drawing pages by hand needed no signature. | Attaching a drawing by hand needs a signature ("Attach a drawing by hand"). | It replaces what M-Files would supply, so a person stands behind it. | Yes | | Admin could write anything on a filed packet with an IT reason. | Admin cannot write a filed packet except through ADMIN_REOPEN (with a reason and a signature), and SET_DATABOOK_AT. | A filed packet is sealed. A change breaks the seal on purpose, on record. | No | diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 4ab6fc0..6acab59 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -1,6 +1,6 @@ # Packet Station schema -Status: phases 1 to 3 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches). Phase 9 adds the notification tables. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema". +Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 9 adds the notification rules and delivery. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema". ## Conventions @@ -82,6 +82,19 @@ The trigger `ps_packet_touch` sets `updated_at` on every UPDATE. It does not tou - `bom_lines`: `item`, `qty`, `part_id` (text), `description`, `dwg_ref`, `cl_item`, `length`, `material`, `heat`, `na`, `issued`, `loc`, `pulled`, `pulled_at`, `pulled_by`, `mtr_asset_id`, `src`, `vkey`, `whs`, `comm`, `cons`, `sub`, `grp`, `sort`, `row_version`. Unique on (`packet_id`, `item`). - `bom_shorts`: `bom_line_id` (cascades with the line), `qty`, `qty_open`, `reason`, `ref`, `eta`, `note`, `opened_at`, `opened_by`, `closed_at`, `row_version`. At most one open short per line (unique on `bom_line_id` where `closed_at` is null). - `short_receipts`: append-only. `short_id`, `packet_id`, `qty`, `heat`, `note`, `at`, `by`. + +What the warehouse columns mean in practice (phase 4): + +- `bom_lines.pulled`: the line is off the shelf. The first tick sets `pulled_at` and `pulled_by`, and sets `issued` to `qty` when it was empty. Unticking clears all three of `pulled`, `pulled_at` and `pulled_by`. The first tick while the warehouse stage is incoming or on hold also starts pulling (a `packet_wh` row with `auto` true). +- `bom_lines.issued`: how many pieces went out. Less than `qty` means the rest should be on a short. +- `bom_lines.loc`: where the parts were taken from, as the warehouse types it. +- `bom_lines.heat`: the heat number of the pieces issued, checked against the heat pattern. A receipt adds its heat to the line, so a line with parts from two heats reads `H1 / H2` (heats joined with " / ", no repeats). +- `bom_lines.na`: the part has no traceability. An N/A line needs no heat, takes no heat, and does not count toward the release gate. +- `bom_lines.mtr_asset_id`: the mill test report for the line, an `assets` row that must be a PDF, PNG or JPEG. Null clears it. +- A line "needs a heat" when it is not N/A, not a sub-assembly (`sub` is `Y`), is pulled, has `issued` above 0 and has no heat. The release gate counts these lines (see [POLICY.md](POLICY.md)). +- `bom_shorts`: one open short per line at most. Opening a short sets the line's `issued` to `qty` minus the short quantity and ticks it pulled. `qty` is what was short in total, `qty_open` what is still missing. `reason` is one of the closed short reasons, `ref` the PO or requisition, `eta` the expected date. Editing an open short keeps what was already received in `qty`. "Not short after all" closes it (`closed_at`) and sets `issued` to the line's `qty`. While a short is open, the line's heat, issued and location change only through the short. +- `short_receipts`: one row per delivery, never changed. Each receipt lowers `qty_open`, adds its pieces to the line's `issued` and its heat to the line's `heat`. The receipt that brings `qty_open` to 0 closes the short. +- `packet_wh`: `reason` is one of the closed hold reasons when the stage is `hold`, and `note` is up to 200 characters. - `packet_stages`: one row per visit to a shop department. `dept_code`, `station_id`, `welder_id`, `welder_stamp`, `checklist` jsonb (key to `{done, at, by, stamp}`, written per key with `jsonb_set`), `filler`, `filler_lot`, `preheat`, `notes`, the clock (`clock_elapsed_ms`, `clock_running_since`, `clock_started_at`), `qc_snapshot`, `opened_at`, `closed_at`, `row_version`. At most one open stage per packet. - `welds`: `weld_no` (from `next_weld_no`), `page`, `fx` and `fy` (fractions of the page), `wo_base`, `piece`, `items` int[], `type`, `size`, `process`, `stamp`, `welder_id`, `dept_code`, `stage_id`, `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `weld_no`) among live rows. - `heat_tags`: `tag_no` (from `next_heat_no`), `page`, `item`, `fx`, `fy`, `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `tag_no`) among live rows. @@ -107,6 +120,17 @@ Every who column references `users (id)`. Every `signature_id` references `signa ## Documents (phases 2, 4 and 6) - `assets`: `id` uuid, `sha256` (indexed, for dedupe), `size`, `mime`, `original_name`, `storage_key` (the path under ASSETS_DIR, unique), `source` (`upload` by default), `source_ref` jsonb, `pages`, `uploaded_by`, `uploaded_at`, `packet_id` (the packet it was uploaded for, without a foreign key; `packet_pages` and `packet_docs` are the real links). + +Asset kinds (phase 4). An upload names its kind in the `purpose` field, and `source_ref` keeps `{"purpose": kind}`. No purpose means `document`. The type is read from the content, never from the name or the browser. + +| Kind | Types | Limit (setting) | +|---|---|---| +| drawing | PDF | 25 MB (`upload_max_bytes`) | +| mtr | PDF, PNG, JPEG | 25 MB (`upload_max_bytes`) | +| document | PDF, PNG, JPEG | 25 MB (`upload_max_bytes`) | +| bom | XLSX, CSV | 5 MB (`upload_max_bytes_bom`) | + +`mime` is one of `application/pdf`, `image/png`, `image/jpeg`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` and `text/csv`. An XLSX is a zip that holds `[Content_Types].xml` and `xl/workbook.xml`; a plain zip is refused. A CSV is UTF-8 text with no NUL and no control character other than tab, CR and LF. An upload the virus scanner refuses is never stored, so it has no row. - `packet_docs`: `asset_id`, `kind`, `title`, `added_at`, `added_by`, soft delete. - `qc_docs`: `project_id`, `unit_id`, `type`, `rev`, `asset_id`, `auto`, `superseded_by` (the newer row), `added_at`, `added_by`. - `itps`: one per unit (`unit_id` unique). `template` jsonb, frozen when the ITP is made. `status` (itp_status), `signoff_*` (with the signature and the signed PDF asset), `reopen_*`, `version`. @@ -134,9 +158,13 @@ Phase 3, migration 0004: - `job_schedule`: one row per job (`job` PK). `next_run_at`, `interval_seconds`, `paused`, `updated_at`. Written by the worker after each run for the Admin tab. Seeded for `visual-feed`, `drawing-lookup` and `housekeeping`. - `worker_heartbeat`: a single row (`id` = 1 by CHECK). `at`, `build`, `host`, `pid`, `running` text[] (the jobs running now). Upserted every 30 seconds. -## Notifications (phase 9) +## Notifications (phases 4 and 9) -Rules, recipients, exclusions, outbox, sent log, state. +Phase 4, migration 0005: + +- `notification_events`: one row per alert. `id` bigserial, `kind` (text, for example `assets_quota`), `severity` (`info`, `warning` or `critical` by CHECK, default `warning`), `subject` (what the alert is about, for example `assets`), `detail` jsonb (for the quota alert, the volume usage: `total`, `used`, `free`, `pct`, `warn`), `created_at`, `resolved_at` (set when the condition clears), `acknowledged_at` and `acknowledged_by` (an Admin saw it). The unique index `notification_events_open` on (`kind`, `coalesce(subject, '')`) where `resolved_at` is null keeps one open alert per kind and subject, so a check that runs again does not stack rows. The table is not append-only: the app sets `resolved_at` and the acknowledgement. 0005 also grants `ps_app` USAGE and SELECT on every sequence in the schema. + +Phase 9 builds the delivery on this table: rules, recipients, exclusions, outbox, sent log, state. ## VISUAL and drawing caches (phase 3) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index ba9cb6e..6a7aaf8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2,7 +2,7 @@ This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md). -Status: phase 3. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Phase 3 adds the worker controls (read-only VISUAL and M-Files reads, stack guard, advisory locks, redacted errors, audited system writes). No requirement ID closes in phase 3. Every other control names the phase that builds it. +Status: phase 4. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Phase 3 adds the worker controls (read-only VISUAL and M-Files reads, stack guard, advisory locks, redacted errors, audited system writes). No requirement ID closes in phase 3. Phase 4 completes the upload pipeline (a kind per upload with its own types and limit, XLSX and CSV by content, the optional ClamAV scan, the assets volume quota alert) and adds the warehouse rules (the heat gate on release, closed hold and short reasons, receive by custody). Phase 4 closes D5. Every other control names the phase that builds it. Contents: @@ -29,7 +29,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro | 11 | PIN sign-off (electronic signature) | Server-verified signature with the PIN or the domain password, stored with who, when, what, factor and a content hash. | 1 | | 12 | IT edit reasons | Kept. Admin writes need a reason and are marked as IT edits in the packet history and the audit. | 2 | | 13 | Screen lock | Server-enforced sessions. The browser reports idle and background state; the server records it and refuses an idle or locked session. Unlock is a step-up check on the server. | 1 | -| 14 | Input and upload validation | On the server: field limits on every write, uploads typed by content, size limits, sanitised names. | 2, 4 | +| 14 | Input and upload validation | On the server: field limits on every write, closed reason sets, uploads typed by content per kind (drawing PDF; MTR and document PDF, PNG or JPEG; BOM XLSX or CSV), a size limit per kind (25 MB, BOM 5 MB), sanitised names, an optional ClamAV scan before anything is stored, and a quota alert on the assets volume. Built. | 2, 4 | | 15 | Second-admin approval | Superseded. Roles come only from AD groups and the group-to-role map is derived, so there is nothing to grant in the app. | 1 | | 16 | Security alerts and automatic integrity check | Worker jobs and the Notifications tab (email through app-03). | 9 | | 17 | Controlled-copy stamp on data books | Applied to server-built PDFs and signed with the seal key. | 7 | @@ -74,14 +74,31 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro - One policy in Python on the server (`server/packetstation/policy/`), pure functions with no I/O. Every write goes: session, policy, transition, validation, version check, packet log row, audit row, NOTIFY, in one transaction with the packet row locked. Nothing in the browser decides. - Every packet read returns `can: [...]`, `blocked: {...}` and `holder`, so the browser enables controls from the server's answer. The server re-checks on the write. - A workflow step taken twice is refused with 409 and the current state (compare-and-set on the expected status and department). Child rows carry a row version checked with If-Match. Weld, heat tag, page and change order numbers are assigned by the server under the lock. -- Preconditions (heat map answered, drawing attached, BOM pulled or short, checklist complete, welds recorded, Q.C. items complete, closed in VISUAL before filing) run on the server and are never bypassed, Admin included. +- Preconditions (heat map answered, drawing attached, BOM pulled or short, heats recorded on pulled lines, checklist complete, welds recorded, Q.C. items complete, closed in VISUAL before filing) run on the server and are never bypassed, Admin included. - Admin edits outside custody need an IT reason of at least 8 characters, recorded in the packet log and the audit row (`it_reason`). An Admin cannot write a filed packet except through the reopen action, which needs a reason and a signature and breaks the seal. - Signatures (PIN or domain password, verified in the same transaction) on submit, send to Q.C., Q.C. accept and reject, route, file, reopen, change order issue, approve, reject and verify, the revision approval and hand drawing uploads. - Input: every string is cleaned (control and invisible characters removed, NFC, trimmed) and cut to its field limit; heat numbers, stamps, project numbers and lots are checked against the old patterns; JSON bodies over 200 KB are refused. -- Uploads: typed by content (PDF, PNG, JPEG), 25 MB limit read in chunks, sha256, stored by id under the assets volume, served only with a session and with `Content-Disposition` and `X-Content-Type-Options: nosniff`. +- Uploads: typed by content (PDF, PNG, JPEG), 25 MB limit read in chunks, sha256, stored by id under the assets volume, served only with a session and with `Content-Disposition` and `X-Content-Type-Options: nosniff`. Phase 4 completes it (see "Uploads and storage"). - The filed lock: a filed packet accepts only the data book date and the Admin reopen. Filing writes an HMAC-SHA256 seal with the server seal key; a reopen keeps the old seal with who broke it and why. - Details in [POLICY.md](POLICY.md). +### Uploads and storage (phase 4) + +- Every upload names a kind (`purpose`): drawing, mtr, document or bom. No purpose means document. An unknown kind is 422. +- The type is read from the content, never from the file name or the browser's type. A drawing is a PDF. An MTR or a document is a PDF, PNG or JPEG. A BOM is an XLSX or a CSV. A wrong type for the kind is 415 `unsupported_type` with a message that names the allowed types. +- An XLSX must be a zip that lists `[Content_Types].xml` and `xl/workbook.xml`; a plain zip is refused. A CSV has no magic, so it is accepted only as a BOM, and only when its first 64 KB decode as UTF-8 with no NUL and no control character other than tab, CR and LF. +- Limits per kind: 25 MB (`upload_max_bytes`) for drawing, mtr and document, 5 MB (`upload_max_bytes_bom`) for a BOM. The Content-Length is checked first, then the bytes as they are read. Over the limit is 413 `too_large`. +- XLSX and CSV are always served as an attachment, never inline. Every blob keeps `Content-Disposition` and `nosniff`. +- Optional ClamAV (requirement D5). When `CLAMAV_HOST` is set, every upload and every file the worker stores (drawings from M-Files) is streamed to clamd with `zINSTREAM` after it is read in full and before it is deduped or placed. A hit is 422 `infected`, nothing is stored, and an `asset.rejected` audit row keeps the signature, size and type. A scanner that cannot be reached refuses the upload with 503 `scan_unavailable` (fail closed). With `CLAMAV_HOST` empty there is no scan. +- Quota alert. After every stored file and in the housekeeping job, the assets volume usage is checked against `ASSETS_QUOTA_WARN_PCT` (85 by default). Over it, one open `notification_events` row of kind `assets_quota` is written and audited as `storage.quota_warning`. Back under, the row is resolved. A failed check never fails an upload. Admin sees usage, the alert and the scanner on the Storage tab and can acknowledge an alert (`alert.acknowledged`). Phase 9 sends the email. + +### Warehouse rules (phase 4) + +- Release to the supervisor is refused on the server until every line is pulled or short and every pulled line that issued pieces has a heat (N/A and sub-assembly lines excepted). Heat numbers are checked against the old pattern. +- Hold reasons and short reasons are closed sets on the server. +- Short receipts go to the warehouse in any status but filed, or to the holder of the packet through the BOM edit rule. Nobody else. +- An MTR on a line must be a PDF, PNG or JPEG asset. + ### Worker (phase 3) - The worker publishes no port and joins no proxy network. It reaches out to VISUAL and M-Files only; nothing reaches in. Admin and Clerical reach it only through rows in PostgreSQL (`job_requests`, the `feed` settings row) and NOTIFY. @@ -99,6 +116,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro - `packetstation doctor` checks that an audit UPDATE fails. - Every identity event is a row: sign-in, failed sign-in with a reason, sign-out, lock, unlock, session ended, PIN set, disabled or cleared, device registered or revoked, signature, AD refresh. Each carries the user, session, roles, client address and a small detail object, never a secret. - `signin_attempts` and `signatures` are append-only in the same way. +- Phase 4 audit actions: `asset.rejected` (an upload the virus scan refused, with `reason`, `signature`, `size` and `mime`), `storage.quota_warning` (the assets volume went over the warning threshold, with the usage) and `alert.acknowledged` (an Admin acknowledged an open alert). Every warehouse write is a `packet.` row with `detail.item`, the warehouse stage before and after, and `detail.qty` on short writes. ### CI identity fixtures (phase 1) @@ -142,7 +160,7 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved | A4 | Closed, phase 0 | | D1, D2, D4 | Deferred (envelope encryption, Key Vault) | | D3 | IT's (host disk encryption on docker-1) | -| D5 | Closed, phase 4 (optional ClamAV) | +| D5 | Closed, phase 4 (optional ClamAV: a `clamav` compose profile; when `CLAMAV_HOST` is set every upload and every stored drawing is scanned before it is kept, a hit is refused and audited, and an unreachable scanner refuses uploads; off when the host is not set) | | D6, D7 | Deferred (retention period and customer copy need a decision from Seth and Q.C.) | | M1 | Closed, phase 2 (append-only audit and packet log written only by the server; every packet write carries both) | | M2 | Closed, phase 7 | diff --git a/frontend/src/api/packets.ts b/frontend/src/api/packets.ts index 614a2c4..2e67457 100644 --- a/frontend/src/api/packets.ts +++ b/frontend/src/api/packets.ts @@ -4,6 +4,7 @@ import { asList, ifMatch, qs } from "./query"; import type { AttachPagesBody, BomLine, + BomPatch, FieldCas, Guarded, LogRow, @@ -12,7 +13,10 @@ import type { PacketHeader, PacketListQuery, PacketRead, + ReceiptBody, + ShortBody, TransitionBody, + WhStageBody, WorkOrder, } from "./types"; @@ -68,4 +72,18 @@ export const packetsApi = { api.post(`${base(id)}/change-orders/${no}/reject`, body), coHold: (id: number, no: number, hold: boolean, extra: Guarded = {}) => api.post(`${base(id)}/change-orders/${no}/hold`, { hold, ...extra }), + + /* Phase 4: the warehouse. */ + /** One BOM line. If-Match is the line's row_version. */ + patchBomLine: (id: number, item: number, rowVersion: number, body: BomPatch, g: Guarded = {}) => + request(`${base(id)}/bom/${item}`, { method: "PATCH", body: { ...body, ...g }, headers: ifMatch(rowVersion) }), + /** Opens the short on a line, or updates the open one. expect_open says which the caller saw. */ + saveShort: (id: number, item: number, body: ShortBody, g: Guarded = {}) => + api.post(`${base(id)}/bom/${item}/short`, { ...body, ...g }), + /** "Not short after all": closes the open short. */ + clearShort: (id: number, item: number, body: { expect_open: boolean }, g: Guarded = {}) => + api.delWithBody(`${base(id)}/bom/${item}/short`, { ...body, ...g }), + receiveShort: (id: number, item: number, body: ReceiptBody, g: Guarded = {}) => + api.post(`${base(id)}/bom/${item}/short/receipts`, { ...body, ...g }), + setWhStage: (id: number, body: WhStageBody, g: Guarded = {}) => api.post(`${base(id)}/wh`, { ...body, ...g }), }; diff --git a/frontend/src/api/storage.ts b/frontend/src/api/storage.ts new file mode 100644 index 0000000..e1e2dbe --- /dev/null +++ b/frontend/src/api/storage.ts @@ -0,0 +1,10 @@ +// Admin: the assets volume, the scanner and the open alerts (phase 4 contract, api/admin.py). +import { api } from "./client"; +import { asList } from "./query"; +import type { AlertRow, StorageInfo } from "./types"; + +export const storageApi = { + storage: (signal?: AbortSignal) => api.get("/api/admin/storage", signal), + alerts: async (signal?: AbortSignal): Promise => asList(await api.get("/api/admin/alerts", signal), "alerts"), + ack: (id: number) => api.post(`/api/admin/alerts/${encodeURIComponent(String(id))}/ack`, {}), +}; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a241ba1..1da1870 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -175,6 +175,17 @@ export interface PacketCard { co_pending?: boolean; /** Phase 3: every leg is closed in VISUAL (observed). */ visual_closed?: boolean; + /** Phase 4: the current warehouse row, on every card. */ + wh?: WhInfo | null; + wh_sent_at?: string | null; + visual_rel?: string | null; + /** Lines not N/A. */ + bom_total?: number; + /** Lines not N/A that are pulled or have an open short. */ + bom_pulled?: number; + bom_missing_heat?: number; + /** The released column only: the newest wh_release transition. */ + released_at?: string | null; } export interface PacketList { @@ -229,7 +240,16 @@ export interface BomShort { eta: string | null; note: string | null; opened_at: string | null; - receipts?: unknown[]; + receipts?: ShortReceipt[]; + row_version?: number; +} + +export interface ShortReceipt { + qty: number | string; + heat: string | null; + note: string | null; + at: string | null; + by?: WhoRef | null; } export interface BomLine { @@ -249,6 +269,8 @@ export interface BomLine { pulled?: boolean; mtr_asset_id?: string | null; src?: string | null; + /** "Y" on a sub-assembly line: its heats are recorded on its own packet. */ + sub?: string | null; short?: BomShort | null; row_version?: number; } @@ -310,7 +332,7 @@ export interface PacketRead { heat_table: unknown[]; stage: Record | null; stages: unknown[]; - wh: Record | null; + wh: WhInfo | null; qc: Record | null; qc_history: unknown[]; change_orders: ChangeOrder[]; @@ -654,3 +676,131 @@ export interface ResourceMapRow { rows_last_run: number | null; updated_at: string | null; } + +/* ---------- Phase 4: warehouse ---------- */ + +export type WhStage = "incoming" | "pulling" | "hold" | "pulled"; + +/** The current packet_wh row. */ +export interface WhInfo { + stage: WhStage; + by: WhoRef | null; + at: string | null; + reason: string | null; + note: string | null; + auto: boolean; +} + +/** POST /api/packets/{id}/wh. */ +export interface WhStageBody { + stage: WhStage; + expect_stage: WhStage; + reason?: string; + note?: string; +} + +/** POST /api/packets/{id}/bom/{item}/short. */ +export interface ShortBody { + qty: number; + reason: string; + ref: string | null; + eta: string | null; + note: string | null; + heat?: string; + expect_open: boolean; +} + +/** POST /api/packets/{id}/bom/{item}/short/receipts. */ +export interface ReceiptBody { + qty: number; + heat: string | null; + note: string | null; +} + +/** PATCH /api/packets/{id}/bom/{item}. */ +export interface BomPatch { + heat?: string | null; + na?: boolean; + issued?: number | null; + loc?: string | null; + pulled?: boolean; + mtr_asset_id?: string | null; +} + +/** One open short on GET /api/warehouse/board. */ +export interface ShortRow { + packet: { + id: number; + unit_number: string; + op: string | null; + drawing: string | null; + status: PacketStatus; + wh_stage: WhStage | null; + station: StationRef | null; + holder: string; + dept: DeptRef; + }; + item: number; + bom_line_id: number; + description: string | null; + part_id: string | null; + material: string | null; + length: string | null; + qty: number | string; + qty_open: number | string; + reason: string | null; + ref: string | null; + eta: string | null; + note: string | null; + opened_at: string | null; + age_days: number; + late: boolean; + short_id: number; + row_version: number; + /** Not in the contract. A receipt needs no heat on an N/A line. */ + na?: boolean; +} + +export type BoardColumn = WhStage | "released"; + +export interface BoardResponse { + kpis: { waiting: number; pulling: number; hold: number; pulled: number; open_shorts: number; released_24h: number }; + columns: Record; + shorts: ShortRow[]; +} + +export interface BoardQuery { + q?: string; + project_id?: number; + unit_id?: number; + dept?: string; +} + +/* ---------- Phase 4: storage and alerts (Admin) ---------- */ + +export interface VolumeUsage { + total: number; + used: number; + free: number; + pct: number; + warn: boolean; +} + +export interface AlertRow { + id: number; + kind: string; + severity: "info" | "warning" | "critical" | string; + subject: string | null; + detail: Record; + created_at: string; + resolved_at: string | null; + acknowledged_at: string | null; + acknowledged_by: WhoRef | string | null; +} + +export interface StorageInfo { + usage: VolumeUsage; + quota_warn_pct: number; + alert: AlertRow | null; + clamav: { configured: boolean; host: string | null; reachable: boolean | null }; +} diff --git a/frontend/src/api/warehouse.ts b/frontend/src/api/warehouse.ts new file mode 100644 index 0000000..32e8093 --- /dev/null +++ b/frontend/src/api/warehouse.ts @@ -0,0 +1,22 @@ +// The warehouse board (phase 4 contract, api/warehouse.py). +import { api } from "./client"; +import { qs } from "./query"; +import type { BoardQuery, BoardResponse, PacketCard } from "./types"; + +const COLUMNS = ["incoming", "pulling", "hold", "pulled", "released"] as const; + +/** Every column and number present, even when the server leaves one out. */ +function normalise(raw: Partial | null): BoardResponse { + const cols = (raw?.columns ?? {}) as Partial>; + const columns = Object.fromEntries(COLUMNS.map((c) => [c, Array.isArray(cols[c]) ? cols[c] : []])) as BoardResponse["columns"]; + return { + kpis: { waiting: 0, pulling: 0, hold: 0, pulled: 0, open_shorts: 0, released_24h: 0, ...(raw?.kpis ?? {}) }, + columns, + shorts: Array.isArray(raw?.shorts) ? raw.shorts : [], + }; +} + +export const warehouseApi = { + board: async (q: BoardQuery = {}, signal?: AbortSignal): Promise => + normalise(await api.get | null>(`/api/warehouse/board${qs({ ...q })}`, signal)), +}; diff --git a/frontend/src/components/Card.test.tsx b/frontend/src/components/Card.test.tsx new file mode 100644 index 0000000..d009605 --- /dev/null +++ b/frontend/src/components/Card.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it } from "vitest"; +import { makeCard } from "../test/packets"; +import { Card } from "./Card"; + +const who = { id: "u1", display_name: "Wade Warehouse" }; + +describe("Card", () => { + it("shows the WH pill and line on a warehouse packet, for every role", () => { + render( + + + , + ); + expect(screen.getByText("WH · on hold")).toHaveClass("pill", "st-warehouse", "wh-hold"); + expect(screen.getByText("On hold: Drawing / BOM problem · Rev B on the floor · <1 min")).toHaveClass("whline"); + expect(screen.queryByText("With warehouse")).not.toBeInTheDocument(); + }); + + it("keeps the status pill and no line outside the warehouse", () => { + render( + + + , + ); + expect(screen.getByText("Ready to assign")).toBeInTheDocument(); + expect(screen.queryByText(/^WH ·/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx index 93ab490..5433844 100644 --- a/frontend/src/components/Card.tsx +++ b/frontend/src/components/Card.tsx @@ -2,16 +2,20 @@ import type { ReactNode } from "react"; import { Link } from "react-router"; import type { PacketCard } from "../api/types"; import { flagClass, jobName, openCoCount, pillClass, statusLabel } from "../lib/packets"; +import { pillClassFor, pillText, WH_STAGES, whLine, whStage } from "../warehouse/stages"; /** One packet on a board. The old card(): job name and pill, title, department, meta, flag line. */ export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) { const shorts = p.open_shorts || 0; const cos = openCoCount(p); + // Every role sees where a warehouse packet really is: the stage pill and the line under it. + const atWh = p.status === "warehouse" && p.wh_stage !== undefined; + const stage = atWh ? whStage(p) : null; return (
{jobName(p)} - {statusLabel(p.status)} + {stage ? {pillText(stage)} : {statusLabel(p.status)}}
{p.title || "Untitled"}
{p.dept.name}
@@ -39,6 +43,7 @@ export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) { {shorts ? {shorts} short : null} {cos ? CO {cos} : null} + {stage ?
{whLine(p)}
: null} {p.flag ?
⚑ {p.flag}
: null} {p.co_pending ?
Change from VISUAL to approve
: null} {p.visual_closed && p.status !== "filed" ?
Closed in VISUAL
: null} diff --git a/frontend/src/components/FindBar.tsx b/frontend/src/components/FindBar.tsx index 33c4204..53954c2 100644 --- a/frontend/src/components/FindBar.tsx +++ b/frontend/src/components/FindBar.tsx @@ -8,8 +8,9 @@ interface FindBarProps { onQ: (q: string) => void; pick: UnitPick; onPick: (v: UnitPick) => void; - view: BoardView; - onView: (v: BoardView) => void; + /** The Board | List toggle. Hidden when onView is not given. */ + view?: BoardView; + onView?: (v: BoardView) => void; /** "Showing n packets". */ count: number | null; /** The flag kind filter. Shown when onFlag is given. */ @@ -18,7 +19,7 @@ interface FindBarProps { } /** Find a job: the search box, the project then unit picker, and the Board | List toggle. */ -export function FindBar({ q, onQ, pick, onPick, view, onView, count, flag = "", onFlag }: FindBarProps) { +export function FindBar({ q, onQ, pick, onPick, view = "board", onView, count, flag = "", onFlag }: FindBarProps) { return (
@@ -55,16 +56,18 @@ export function FindBar({ q, onQ, pick, onPick, view, onView, count, flag = "", ) : null} -
-
- - + {onView ? ( +
+
+ + +
-
+ ) : null}
{count !== null ? (
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 4dbb58c..13d4a34 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -81,3 +81,16 @@ export function fmtDay(iso: string | null | undefined): string { if (Number.isNaN(d.getTime())) return iso; return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } + +/** "1.2 GB" from bytes. */ +export function fmtBytes(n: number | null | undefined): string { + if (n === null || n === undefined || !Number.isFinite(n)) return "-"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = n; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${i === 0 ? String(Math.round(v)) : v.toFixed(v < 10 ? 1 : 0)} ${units[i] ?? "B"}`; +} diff --git a/frontend/src/packet/PacketPage.tsx b/frontend/src/packet/PacketPage.tsx index 1402f7f..934d7a6 100644 --- a/frontend/src/packet/PacketPage.tsx +++ b/frontend/src/packet/PacketPage.tsx @@ -15,6 +15,9 @@ import { MoveSheet, SendToDeptSheet } from "./sheets"; import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs"; import { coSummary, coToReview } from "../lib/changeOrders"; import { ChangesTab, VisualBox } from "./visual"; +import type { Write } from "../warehouse/PullTable"; +import { packetMessage, pillClassFor, WH_STAGES, pillText, progress, whLine, whStage } from "../warehouse/stages"; +import { useWhMove } from "../warehouse/useWhMove"; type Tab = "drawing" | "materials" | "workorders" | "changes" | "docs" | "history"; @@ -122,15 +125,18 @@ export function PacketPage() { const versionRef = useRef(null); const fileRef = useRef(null); + // A read that was sent before a write committed can answer after the write's own answer: never go back. + const take = useCallback((p: PacketRead) => setPacket((cur) => (cur && cur.id === p.id && cur.version > p.version ? cur : p)), []); + const reload = useCallback(async () => { try { const p = await packetsApi.get(id); - setPacket(p); + take(p); setLoadError(null); } catch (err) { setLoadError(errorText(err)); } - }, [id]); + }, [id, take]); useEffect(() => { setPacket(null); @@ -150,6 +156,31 @@ export function PacketPage() { const job = packet ? jobName(packet) : ""; + // Line writes: the answer replaces the packet; the error goes back to the row that made it. + const write: Write = useCallback( + async (fn) => { + try { + const r = await run(fn, { sub: job }); + if (r === undefined) return { ok: false }; + if (r && typeof r === "object" && "id" in r) take(r as PacketRead); + else await reload(); + return { ok: true }; + } catch (err) { + return { ok: false, err }; + } + }, + [run, reload, job, take], + ); + + const wh = useWhMove({ + run, + onDone: setPacket, + onError: (err) => { + if (err instanceof ApiError && err.status === 409) void reload(); + setMsg({ ok: false, text: errorText(err) }); + }, + }); + const mutate: Mutate = useCallback( async (fn, opts = {}) => { setBusy(true); @@ -157,7 +188,7 @@ export function PacketPage() { try { const r = await run(fn, { sign: opts.sign, sub: job }); if (r === undefined) return false; - if (r && typeof r === "object" && "id" in r) setPacket(r); + if (r && typeof r === "object" && "id" in r) take(r as PacketRead); else await reload(); if (opts.ok) setMsg({ ok: true, text: opts.ok }); return true; @@ -170,7 +201,7 @@ export function PacketPage() { setBusy(false); } }, - [run, reload, job], + [run, reload, job, take], ); if (!packet) { @@ -272,6 +303,22 @@ export function PacketPage() { const target = targetHours(p); const lotQty = Math.max(0, ...p.work_orders.map((w) => Number(w.qty) || 0)); const backTo = me?.screens.includes("clerical") ? `/clerical/units/${p.unit.id}` : homePath(me?.screens ?? []); + const atWh = p.status === "warehouse"; + const whCan = atWh && can("set_wh_stage"); + const stage = whStage(p); + const whMsg = whCan ? packetMessage(p) : null; + const whTarget = { id: p.id, stage, job, progress: progress(p) }; + const sendBack = async () => { + const reason = await askReason({ + title: "Send back to Clerical", + sub: job, + note: "The packet goes back to Clerical as a draft. Say what needs fixing.", + min: 8, + submitLabel: "Send back to Clerical", + }); + if (reason === null) return; + await transition("wh_send_back", { reason }, "Sent back to Clerical."); + }; const hasDraftControls = draft && ["edit_header", "send_to_warehouse", "release_to_shop", "send_draft_to_qc", "edit_document_set", "move_unit", "remove"].some((a) => can(a) || p.blocked[a]); @@ -473,6 +520,71 @@ export function PacketPage() {
) : null} + {atWh ? ( +
+
+ {pillText(stage)} +
{whLine(p)}
+
+ {whMsg ? ( +
+ {whMsg.head} {whMsg.rest} +
+ ) : null} + {whCan || can("wh_send_back") || can("wh_release") || p.blocked.wh_release ? ( +
+ void sendBack()}> + Send back to Clerical + + {whCan && stage === "incoming" ? ( +
+ +
+ ) : null} + {whCan && stage === "hold" ? ( +
+ +
+ ) : null} + {whCan && stage === "pulling" ? ( +
+ +
+ ) : null} + {whCan && stage === "pulled" ? ( +
+ +
+ ) : null} + {whCan && (stage === "incoming" || stage === "pulling") ? ( +
+ +
+ ) : null} + void transition("wh_release", {}, "Released to supervisor.")} + > + Release to supervisor{p.open_shorts ? ` (${p.open_shorts} short)` : ""} + +
+ ) : null} +
+ ) : null} + {!draft && (can("set_vclosed") || can("file") || p.blocked.file) ? (
@@ -536,7 +648,7 @@ export function PacketPage() {
) : null} - {shownTab === "materials" ? : null} + {shownTab === "materials" ? : null} {shownTab === "workorders" ? : null} {shownTab === "changes" ? : null} {shownTab === "docs" ? : null} @@ -565,6 +677,7 @@ export function PacketPage() { }} /> ) : null} + {wh.sheet} {guardSheet} ); diff --git a/frontend/src/packet/tabs.tsx b/frontend/src/packet/tabs.tsx index 63fbebb..f935966 100644 --- a/frontend/src/packet/tabs.tsx +++ b/frontend/src/packet/tabs.tsx @@ -5,6 +5,9 @@ import { packetsApi } from "../api/packets"; import type { BomLine, Guarded, PacketRead, WorkOrder } from "../api/types"; import { errorText, fmtTime } from "../lib/format"; import { useLoad } from "../lib/useLoad"; +import { MtrViewer } from "../warehouse/MtrViewer"; +import { PullTable, ShortChip, type Write } from "../warehouse/PullTable"; +import { ShortSheet } from "../warehouse/ShortSheet"; export type Mutate = ( fn: (g: Guarded) => Promise, @@ -35,10 +38,28 @@ const toDraft = (b: BomLine): LineDraft => ({ const EMPTY_LINE: LineDraft = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" }; -/** The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). */ -export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) { +/** + * The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). The warehouse gets the pull table + * while the packet is with it. Anyone who may receive a short gets Receive on the line. + */ +export function MaterialsTab({ + p, + mutate, + busy, + write, + reload, +}: { + p: PacketRead; + mutate: Mutate; + busy: boolean; + write?: Write | undefined; + reload?: (() => Promise) | undefined; +}) { const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom"); const [lines, setLines] = useState(null); + const [receiveFor, setReceiveFor] = useState(null); + const [viewFor, setViewFor] = useState(null); + const canReceive = p.can.includes("wh_receive_short") || p.can.includes("edit_bom"); const set = (i: number, k: keyof LineDraft, v: string) => setLines((rows) => (rows ?? []).map((r, j) => (j === i ? { ...r, [k]: v } : r))); const save = async (e: FormEvent) => { @@ -124,6 +145,19 @@ export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat ); } + if (p.status === "warehouse" && p.can.includes("edit_bom") && write && reload) { + return ; + } + + const receiving = receiveFor === null ? undefined : p.bom.find((b) => b.item === receiveFor); + const viewed = viewFor === null ? undefined : p.bom.find((b) => b.item === viewFor); + const receive = async (item: number, body: Parameters[2]): Promise => { + if (!write) return "Receiving is not available here."; + const r = await write((g) => packetsApi.receiveShort(p.id, item, body, g)); + if (r.ok || r.err === undefined) return null; + return errorText(r.err); + }; + return (
{editable ? ( @@ -164,12 +198,29 @@ export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat {show(b.material)} {show(b.length)} - {show(b.heat)} + + {show(b.heat)} + {b.mtr_asset_id ? ( + <> + {" "} + + + ) : null} + - {b.na ? ( + {b.na && !b.short ? ( N/A ) : b.short ? ( - {String(b.short.qty_open)} short +
+ + {canReceive && write ? ( + + ) : null} +
) : b.pulled ? ( Pulled ) : ( @@ -184,6 +235,12 @@ export function MaterialsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat ) : (
No BOM lines on this packet.
)} + {receiving ? ( + setReceiveFor(null)} onReceive={(body) => receive(receiving.item, body)} /> + ) : null} + {viewed?.mtr_asset_id ? ( + setViewFor(null)} /> + ) : null}
); } @@ -305,7 +362,7 @@ export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutat } setUploading(true); try { - const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "doc" }); + const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "document" }); const ok = await mutate((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, title: title.trim() || file.name, ...g }), { ok: "Document added" }); if (ok) { setTitle(""); diff --git a/frontend/src/screens/ScreenRoute.tsx b/frontend/src/screens/ScreenRoute.tsx index 95c8c05..ba32a03 100644 --- a/frontend/src/screens/ScreenRoute.tsx +++ b/frontend/src/screens/ScreenRoute.tsx @@ -5,6 +5,7 @@ import { useAuth } from "../auth/useAuth"; import { AdminPage } from "./admin/AdminPage"; import { ClericalHome } from "./clerical/ClericalHome"; import { PlaceholderScreen } from "./PlaceholderScreen"; +import { WarehouseHome } from "./warehouse/WarehouseHome"; /** A page under a screen, like /clerical/projects/7. Users without the screen go to their first screen. */ export function RequireScreen({ screen, children }: { screen: Screen; children: ReactNode }) { @@ -23,5 +24,6 @@ export function ScreenRoute() { if (!isScreen(screen) || !allowed.includes(screen)) return ; if (screen === "admin") return ; if (screen === "clerical") return ; + if (screen === "warehouse") return ; return ; } diff --git a/frontend/src/screens/admin/AdminPage.tsx b/frontend/src/screens/admin/AdminPage.tsx index 9e6ada6..934a33a 100644 --- a/frontend/src/screens/admin/AdminPage.tsx +++ b/frontend/src/screens/admin/AdminPage.tsx @@ -6,8 +6,9 @@ import { useAuth } from "../../auth/useAuth"; import { errorText, fmtTime } from "../../lib/format"; import { useLoad, type Load } from "../../lib/useLoad"; import { FeedTab, ResourcesTab } from "./FeedTabs"; +import { StorageTab } from "./StorageTab"; -type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources"; +type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources" | "storage"; const TABS: ReadonlyArray<{ id: Tab; label: string }> = [ { id: "users", label: "Users" }, @@ -17,6 +18,7 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [ { id: "attempts", label: "Sign-in attempts" }, { id: "feed", label: "Feed" }, { id: "resources", label: "VISUAL resources" }, + { id: "storage", label: "Storage" }, ]; const ROLES_NOTE = "Roles come from AD groups. Ask IT to change them."; @@ -701,6 +703,7 @@ export function AdminPage() { {tab === "attempts" ? : null} {tab === "feed" ? : null} {tab === "resources" ? : null} + {tab === "storage" ? : null} ); diff --git a/frontend/src/screens/admin/StorageTab.test.tsx b/frontend/src/screens/admin/StorageTab.test.tsx new file mode 100644 index 0000000..ccaffaf --- /dev/null +++ b/frontend/src/screens/admin/StorageTab.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth"; +import { callsTo, mockFetch } from "../../test/fetchMock"; + +const ADMIN = makeMe({ + user: { display_name: "Ann Admin", sam_account: "admin.ann" }, + roles: ["admin"], + screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"], +}); + +const GB = 1024 ** 3; +const ALERT = { + id: 3, + kind: "assets_quota", + severity: "warning", + subject: "assets", + detail: { total: 100 * GB, used: 90 * GB, free: 10 * GB, pct: 90, warn: true }, + created_at: "2026-10-16T07:00:00Z", + resolved_at: null, + acknowledged_at: null, + acknowledged_by: null, +}; + +function base(clamav: { configured: boolean; host: string | null; reachable: boolean | null }) { + return { + "/api/auth/me": { body: ADMIN }, + "/api/departments": { body: DEPARTMENTS }, + "/api/admin/users": { body: [] }, + "/api/admin/storage": { + body: { usage: { total: 100 * GB, used: 90 * GB, free: 10 * GB, pct: 90, warn: true }, quota_warn_pct: 85, alert: ALERT, clamav }, + }, + "/api/admin/alerts": { body: { alerts: [ALERT] } }, + }; +} + +describe("Admin Storage tab", () => { + it("shows the usage, the warning threshold, the scanner and the open alert, and acknowledges it", async () => { + const fetch = mockFetch({ ...base({ configured: true, host: "clamav", reachable: true }), "POST /api/admin/alerts/3/ack": { body: { ...ALERT, acknowledged_at: "2026-10-16T08:00:00Z" } } }); + renderApp("/admin"); + fireEvent.click(await screen.findByRole("tab", { name: "Storage" })); + expect(await screen.findByText(/^Used/)).toHaveTextContent("Used 90 GB of 100 GB (90%)."); + expect(screen.getByRole("meter", { name: "Assets volume used" })).toHaveAttribute("aria-valuenow", "90"); + expect(screen.getByText("Warning at 85%.")).toBeInTheDocument(); + expect(screen.getByText("clamav: reachable")).toBeInTheDocument(); + const status = screen.getByRole("status"); + expect(status).toHaveTextContent("The assets volume is 90% full."); + const table = await screen.findByRole("table", { name: "Open alerts" }); + expect(within(table).getByText("warning")).toBeInTheDocument(); + fireEvent.click(within(status).getByRole("button", { name: "Acknowledge" })); + await waitFor(() => expect(callsTo(fetch, "POST", "/api/admin/alerts/3/ack")).toEqual([{}])); + expect(await screen.findByText("Alert acknowledged.")).toBeInTheDocument(); + }); + + it("says Not configured when no scanner is set", async () => { + mockFetch(base({ configured: false, host: null, reachable: null })); + renderApp("/admin"); + fireEvent.click(await screen.findByRole("tab", { name: "Storage" })); + expect(await screen.findByText("Not configured")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/screens/admin/StorageTab.tsx b/frontend/src/screens/admin/StorageTab.tsx new file mode 100644 index 0000000..b1d744f --- /dev/null +++ b/frontend/src/screens/admin/StorageTab.tsx @@ -0,0 +1,141 @@ +// Admin: the assets volume, the virus scanner and the open alerts (phase 4). +import { useState } from "react"; +import { storageApi } from "../../api/storage"; +import type { AlertRow } from "../../api/types"; +import { errorText, fmtBytes, fmtTime } from "../../lib/format"; +import { useLoad } from "../../lib/useLoad"; + +const whoName = (a: AlertRow["acknowledged_by"]) => (a && typeof a === "object" ? a.display_name : (a ?? "")); + +function alertText(a: AlertRow): string { + if (a.kind === "assets_quota") { + const pct = Number((a.detail as { pct?: unknown }).pct); + return Number.isFinite(pct) ? `The assets volume is ${Math.round(pct)}% full.` : "The assets volume is nearly full."; + } + return a.subject ? `${a.kind.replace(/_/g, " ")}: ${a.subject}` : a.kind.replace(/_/g, " "); +} + +export function StorageTab() { + const [load, reload] = useLoad((s) => storageApi.storage(s)); + const [alerts, reloadAlerts] = useLoad((s) => storageApi.alerts(s)); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + + const ack = async (id: number) => { + setBusy(true); + setMsg(null); + try { + await storageApi.ack(id); + setMsg({ ok: true, text: "Alert acknowledged." }); + reload(); + reloadAlerts(); + } catch (err) { + setMsg({ ok: false, text: errorText(err) }); + } finally { + setBusy(false); + } + }; + + if (load.state === "loading") return
Loading
; + if (load.state === "error") return
{load.message}
; + const s = load.data; + const u = s.usage; + const pct = Math.max(0, Math.min(100, Number(u.pct) || 0)); + const scan = s.clamav; + + return ( +
+ {msg ? ( +
+ {msg.text} +
+ ) : null} + +
+

Assets volume

+
+ Used {fmtBytes(u.used)} of {fmtBytes(u.total)} ({Math.round(pct)}%). {fmtBytes(u.free)} free. +
+
+ +
+
Warning at {s.quota_warn_pct}%.
+ {s.alert ? ( +
+ {alertText(s.alert)} Raised {fmtTime(s.alert.created_at)}. + {s.alert.acknowledged_at ? ( + <> Acknowledged {fmtTime(s.alert.acknowledged_at)}{whoName(s.alert.acknowledged_by) ? ` by ${whoName(s.alert.acknowledged_by)}` : ""}. + ) : ( + <> + {" "} + + + )} +
+ ) : null} +
+ +
+ Virus scanner (ClamAV): + {!scan.configured ? ( + Not configured + ) : ( + + {scan.host ?? "clamav"}: {scan.reachable ? "reachable" : "not reachable"} + + )} +
+ +
+
+

Open alerts

+
+ {alerts.state === "loading" ?
Loading
: null} + {alerts.state === "error" ?
{alerts.message}
: null} + {alerts.state === "ok" && !alerts.data.length ?
No open alerts.
: null} + {alerts.state === "ok" && alerts.data.length ? ( +
+ + + + + + + + + + + {alerts.data.map((a) => ( + + + + + + + + ))} + +
RaisedSeverityWhatAcknowledged +
{fmtTime(a.created_at)}{a.severity}{alertText(a)}{a.acknowledged_at ? `${fmtTime(a.acknowledged_at)} ${whoName(a.acknowledged_by)}` : "-"} + {a.acknowledged_at ? null : ( + + )} +
+
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/screens/warehouse/ShortsTable.tsx b/frontend/src/screens/warehouse/ShortsTable.tsx new file mode 100644 index 0000000..6b862d0 --- /dev/null +++ b/frontend/src/screens/warehouse/ShortsTable.tsx @@ -0,0 +1,88 @@ +import { Link } from "react-router"; +import type { ShortRow } from "../../api/types"; +import { fmtDay } from "../../lib/format"; +import { pillClass, statusLabel } from "../../lib/packets"; +import { pillClassFor, pillText, whStage } from "../../warehouse/stages"; + +export const NO_SHORTS = "No open shorts. When a part isn't on the shelf, mark it Short on the pull and it lands here."; + +const jobOf = (r: ShortRow) => (r.packet.op ? `${r.packet.unit_number} · ${r.packet.op}` : r.packet.unit_number); + +/** Every open short on every live packet, soonest ETA first (the old Open shorts table). */ +export function ShortsTable({ rows, busy, onReceive }: { rows: ShortRow[]; busy: boolean; onReceive: (r: ShortRow) => void }) { + return ( +
+
+

Open shorts

+ parts that went on without the full pull. Receive them here when they come in. +
+ {rows.length ? ( +
+ + + + + + + + + + + + + + + {rows.map((r) => { + const atWh = r.packet.status === "warehouse"; + const stage = atWh ? whStage({ wh_stage: r.packet.wh_stage }) : null; + return ( + + + + + + + + + + + + ); + })} + +
JobItemShortReasonPO / reqETAAgePacket is at +
+ + {jobOf(r)} + +
+ {r.packet.op ?? ""} · {r.packet.drawing ?? ""} +
+
+ {r.item} {r.description ?? ""} +
{[r.part_id, r.material, r.length].filter(Boolean).join(" ")}
+
{String(r.qty_open)} + {r.reason ?? ""} + {r.note ?
{r.note}
: null} +
{r.ref || "-"}{r.eta ? `${fmtDay(r.eta)}${r.late ? " · late" : ""}` : "-"}{r.age_days} d + {stage ? ( + {pillText(stage)} + ) : ( + {statusLabel(r.packet.status)} + )} +
+ {r.packet.holder} + {r.packet.station ? ` · ${r.packet.station.name}` : ""} +
+
+ +
+
+ ) : ( +
{NO_SHORTS}
+ )} +
+ ); +} diff --git a/frontend/src/screens/warehouse/WarehouseHome.test.tsx b/frontend/src/screens/warehouse/WarehouseHome.test.tsx new file mode 100644 index 0000000..3612ba9 --- /dev/null +++ b/frontend/src/screens/warehouse/WarehouseHome.test.tsx @@ -0,0 +1,249 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { BoardResponse, PacketCard, ShortRow } from "../../api/types"; +import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth"; +import { callsTo, mockFetch } from "../../test/fetchMock"; +import { makeCard, makePacket } from "../../test/packets"; + +const WADE = makeMe({ roles: ["warehouse"], screens: ["warehouse"], user: { sam_account: "wh.wade", display_name: "Wade Warehouse" } }); +const who = { id: "u-wade", display_name: "Wade Warehouse" }; +const WELD = { code: "03", name: "Weld Parts" }; +const ELEC = { code: "14", name: "Electrical" }; + +function whCard(id: number, over: Partial = {}): PacketCard { + return makeCard({ + id, + status: "warehouse", + holder: "Warehouse", + unit: { id: 100 + id, number: `L99900${id}` }, + wh_stage: "incoming", + wh: null, + wh_sent_at: "2026-10-15T08:00:00Z", + visual_rel: null, + bom_total: 3, + bom_pulled: 0, + bom_missing_heat: 0, + ...over, + }); +} + +const SHORT: ShortRow = { + packet: { + id: 9, + unit_number: "L999009", + op: "FIT-UP", + drawing: "D-9", + status: "ready", + wh_stage: null, + station: null, + holder: "Weld Parts", + dept: WELD, + }, + item: 2, + bom_line_id: 92, + description: "Pipe 2in", + part_id: "P-2", + material: "A106", + length: "20 ft", + qty: 4, + qty_open: 1, + reason: "Not in stock", + ref: "PO 4471", + eta: "2026-10-01", + note: null, + opened_at: "2026-10-10T08:00:00Z", + age_days: 6, + late: true, + short_id: 5, + row_version: 1, +}; + +function board(over: Partial = {}): BoardResponse { + return { + kpis: { waiting: 3, pulling: 1, hold: 1, pulled: 1, open_shorts: 1, released_24h: 2 }, + columns: { + incoming: [ + whCard(1, { dept: WELD, wh_sent_at: "2026-10-15T10:00:00Z" }), + whCard(2, { dept: ELEC, wh_sent_at: "2026-10-12T10:00:00Z" }), + whCard(3, { dept: WELD, wh_sent_at: "2026-10-14T10:00:00Z" }), + ], + pulling: [whCard(4, { wh_stage: "pulling", wh: { stage: "pulling", by: who, at: "2026-10-16T07:00:00Z", reason: null, note: null, auto: true }, bom_pulled: 1, bom_missing_heat: 1 })], + hold: [ + whCard(5, { + wh_stage: "hold", + wh: { stage: "hold", by: who, at: "2026-10-16T07:00:00Z", reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri", auto: false }, + }), + ], + pulled: [whCard(6, { wh_stage: "pulled", wh: { stage: "pulled", by: who, at: "2026-10-16T07:00:00Z", reason: null, note: null, auto: false }, bom_pulled: 3 })], + released: [makeCard({ id: 7, status: "ready", holder: "Weld Parts", unit: { id: 107, number: "L999007" }, released_at: "2026-10-16T06:00:00Z" })], + }, + shorts: [SHORT], + ...over, + }; +} + +function base(b: BoardResponse = board(), me = WADE) { + return { + "/api/auth/me": { body: me }, + "/api/departments": { body: DEPARTMENTS }, + "/api/warehouse/board": { body: b }, + "/api/projects": { body: [] }, + }; +} + +const column = (title: string) => screen.getByRole("heading", { name: new RegExp(`^${title.replace(/[()]/g, "\\$&")}`) }).closest("section") as HTMLElement; +const cardOf = (name: string) => screen.getByRole("group", { name: new RegExp(`^${name}`) }); + +describe("WarehouseHome", () => { + it("shows the numbers, the five columns, and groups cards by department oldest first", async () => { + mockFetch(base()); + renderApp("/warehouse"); + expect(await screen.findByRole("heading", { name: /^Waiting to pull/ })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Warehouse" })).toBeInTheDocument(); + expect(screen.getByText("Parts pull")).toBeInTheDocument(); + for (const t of ["Waiting to pull", "Being pulled", "On hold", "Pulled, ready for production", "Released to shop (3 days)"]) { + expect(column(t)).toBeInTheDocument(); + } + expect(screen.getByLabelText("Waiting to pull: 3")).toBeInTheDocument(); + expect(screen.getByLabelText("On hold: 1")).toHaveClass("hot"); + expect(screen.getByLabelText("Ready for production: 1")).toHaveClass("hot"); + expect(screen.getByLabelText("Open shorts: 1")).toHaveClass("hot"); + expect(screen.getByLabelText("Released last 24 h: 2")).toBeInTheDocument(); + + const waiting = column("Waiting to pull"); + // Electrical has the oldest packet, so it comes first; Weld Parts cards go oldest first. + const heads = Array.from(waiting.querySelectorAll(".whgrp")).map((g) => g.textContent); + expect(heads).toEqual(["Electrical1", "Weld Parts2"]); + const jobs = Array.from(waiting.querySelectorAll(".whcard .job")).map((j) => j.textContent); + expect(jobs).toEqual(["L999002 · WELD-PART", "L999003 · WELD-PART", "L999001 · WELD-PART"]); + + expect(within(column("Released to shop (3 days)")).getByRole("link", { name: /L999007/ })).toBeInTheDocument(); + }); + + it("shows the pill, the line and the buttons for each stage", async () => { + mockFetch(base()); + renderApp("/warehouse"); + const inc = await screen.findByRole("group", { name: "L999002 · WELD-PART, Waiting to pull" }); + expect(within(inc).getByText("WH · waiting")).toBeInTheDocument(); + expect(within(inc).getByText(/^Waiting .*, not started yet$/)).toBeInTheDocument(); + expect(within(inc).getByRole("button", { name: "Start pulling" })).toHaveClass("pri"); + expect(within(inc).getByRole("button", { name: "Hold" })).toBeInTheDocument(); + expect(within(inc).getByText((_, el) => el?.tagName === "SPAN" && el.textContent === "0/3 lines pulled")).toBeInTheDocument(); + + const pul = cardOf("L999004"); + expect(within(pul).getByText("WH · being pulled")).toBeInTheDocument(); + expect(within(pul).getByText(/^Being pulled by Wade Warehouse/)).toBeInTheDocument(); + expect(within(pul).getByRole("button", { name: "Mark pulled" })).not.toHaveClass("pri"); + expect(within(pul).getByText("1 heat missing")).toBeInTheDocument(); + + const hold = cardOf("L999005"); + expect(within(hold).getByText("WH · on hold")).toBeInTheDocument(); + expect(within(hold).getByText(/^On hold: Waiting on purchasing \/ PO · PO 4471 due Fri/)).toBeInTheDocument(); + expect(within(hold).getByRole("button", { name: "Resume pulling" })).toHaveClass("pri"); + expect(within(hold).queryByRole("button", { name: "Hold" })).not.toBeInTheDocument(); + + const done = cardOf("L999006"); + expect(within(done).getByText("WH · pulled")).toBeInTheDocument(); + expect(within(done).getByRole("button", { name: "Release…" })).toBeInTheDocument(); + expect(within(done).getByRole("button", { name: "Back to pulling" })).toBeInTheDocument(); + }); + + it("shows no stage buttons to a role outside the warehouse", async () => { + const wes = makeMe({ roles: ["shop:03:supervisor"], screens: ["warehouse", "supervisor"] }); + mockFetch(base(board(), wes)); + renderApp("/warehouse"); + const inc = await screen.findByRole("group", { name: /^L999002/ }); + expect(within(inc).getByText("WH · waiting")).toBeInTheDocument(); + expect(within(inc).queryByRole("button")).not.toBeInTheDocument(); + }); + + it("Start pulling posts the stage with the stage it saw", async () => { + const fetch = mockFetch({ ...base(), "POST /api/packets/2/wh": { body: makePacket({ id: 2, status: "warehouse" }) } }); + renderApp("/warehouse"); + const inc = await screen.findByRole("group", { name: /^L999002/ }); + fireEvent.click(within(inc).getByRole("button", { name: "Start pulling" })); + await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toEqual([{ stage: "pulling", expect_stage: "incoming" }])); + }); + + it("the hold sheet refuses Other without a note, then posts the reason and note", async () => { + const fetch = mockFetch({ ...base(), "POST /api/packets/2/wh": { body: makePacket({ id: 2, status: "warehouse" }) } }); + renderApp("/warehouse"); + const inc = await screen.findByRole("group", { name: /^L999002/ }); + fireEvent.click(within(inc).getByRole("button", { name: "Hold" })); + const sheet = await screen.findByRole("dialog", { name: "Put on hold" }); + fireEvent.click(within(sheet).getByLabelText("Other")); + fireEvent.click(within(sheet).getByRole("button", { name: "Put on hold" })); + expect(within(sheet).getByRole("alert")).toHaveTextContent("Say what it's waiting on."); + expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toHaveLength(0); + + fireEvent.click(within(sheet).getByLabelText("Waiting on purchasing / PO")); + fireEvent.change(within(sheet).getByLabelText(/^Note/), { target: { value: "PO 4471 due Fri" } }); + fireEvent.click(within(sheet).getByRole("button", { name: "Put on hold" })); + await waitFor(() => + expect(callsTo(fetch, "POST", "/api/packets/2/wh")).toEqual([ + { stage: "hold", expect_stage: "incoming", reason: "Waiting on purchasing / PO", note: "PO 4471 due Fri" }, + ]), + ); + expect(screen.queryByRole("dialog", { name: "Put on hold" })).not.toBeInTheDocument(); + }); + + it("Mark pulled asks first when lines remain", async () => { + const fetch = mockFetch({ ...base(), "POST /api/packets/4/wh": { body: makePacket({ id: 4, status: "warehouse" }) } }); + renderApp("/warehouse"); + const pul = await screen.findByRole("group", { name: /^L999004/ }); + fireEvent.click(within(pul).getByRole("button", { name: "Mark pulled" })); + const sheet = await screen.findByRole("dialog", { name: "Not everything is pulled yet" }); + expect(sheet).toHaveTextContent("2 lines not ticked Pulled and 1 heat number missing. Mark it pulled anyway?"); + expect(callsTo(fetch, "POST", "/api/packets/4/wh")).toHaveLength(0); + fireEvent.click(within(sheet).getByRole("button", { name: "Mark pulled" })); + await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/4/wh")).toEqual([{ stage: "pulled", expect_stage: "pulling" }])); + }); + + it("a drop on the released column is refused with the toast; a drop on a stage moves the card", async () => { + const fetch = mockFetch({ ...base(), "POST /api/packets/6/wh": { body: makePacket({ id: 6, status: "warehouse" }) } }); + renderApp("/warehouse"); + const done = await screen.findByRole("group", { name: /^L999006/ }); + const drag = (to: string) => { + const col = document.querySelector(`[data-whcol="${to}"]`) as HTMLElement; + const spy = vi.fn(() => [col]); + Object.defineProperty(document, "elementsFromPoint", { value: spy, configurable: true }); + fireEvent.pointerDown(done, { clientX: 10, clientY: 10, button: 0, pointerType: "mouse" }); + fireEvent.pointerMove(window, { clientX: 60, clientY: 40 }); + fireEvent.pointerUp(window, { clientX: 60, clientY: 40 }); + }; + drag("released"); + expect(await screen.findByRole("status")).toHaveTextContent("Open the packet and use Release to supervisor. It checks heats and shorts first."); + expect(callsTo(fetch, "POST", "/api/packets/6/wh")).toHaveLength(0); + // A drag never counts as a click: the packet does not open. + fireEvent.click(done); + expect(screen.getByRole("heading", { name: "Warehouse" })).toBeInTheDocument(); + + drag("pulling"); + await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/6/wh")).toEqual([{ stage: "pulling", expect_stage: "pulled" }])); + }); + + it("lists the open shorts and receives one with a heat", async () => { + const fetch = mockFetch({ + ...base(), + "POST /api/packets/9/bom/2/short/receipts": { body: makePacket({ id: 9, status: "ready" }) }, + }); + renderApp("/warehouse"); + const table = await screen.findByRole("table", { name: "Open shorts" }); + expect(within(table).getByText("PO 4471")).toBeInTheDocument(); + expect(within(table).getByText(/· late$/)).toBeInTheDocument(); + fireEvent.click(within(table).getByRole("button", { name: "Receive" })); + const sheet = await screen.findByRole("dialog", { name: "Receive short part" }); + fireEvent.click(within(sheet).getByRole("button", { name: "Receive" })); + expect(within(sheet).getByRole("alert")).toHaveTextContent("Scan or type the heat number of the part received."); + fireEvent.change(within(sheet).getByLabelText("Heat"), { target: { value: "HT-9" } }); + fireEvent.click(within(sheet).getByRole("button", { name: "Receive" })); + await waitFor(() => expect(callsTo(fetch, "POST", "/api/packets/9/bom/2/short/receipts")).toEqual([{ qty: 1, heat: "HT-9", note: null }])); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Receive short part" })).not.toBeInTheDocument()); + }); + + it("says so when there are no open shorts", async () => { + mockFetch(base(board({ shorts: [] }))); + renderApp("/warehouse"); + expect(await screen.findByText("No open shorts. When a part isn't on the shelf, mark it Short on the pull and it lands here.")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/screens/warehouse/WarehouseHome.tsx b/frontend/src/screens/warehouse/WarehouseHome.tsx new file mode 100644 index 0000000..092f332 --- /dev/null +++ b/frontend/src/screens/warehouse/WarehouseHome.tsx @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useState } from "react"; +import { ApiError } from "../../api/client"; +import { packetsApi } from "../../api/packets"; +import type { BoardColumn, PacketCard, ReceiptBody, ShortRow, WhStage } from "../../api/types"; +import { warehouseApi } from "../../api/warehouse"; +import { useAuth } from "../../auth/useAuth"; +import { FindBar } from "../../components/FindBar"; +import { Kpis } from "../../components/Kpis"; +import type { UnitPick } from "../../components/PickUnit"; +import { useGuard } from "../../components/useGuard"; +import { useChanged } from "../../lib/changes"; +import { errorText } from "../../lib/format"; +import { jobName } from "../../lib/packets"; +import { useLoad } from "../../lib/useLoad"; +import { ShortSheet } from "../../warehouse/ShortSheet"; +import { cardProgress, isWarehouseUser, whStage } from "../../warehouse/stages"; +import { useWhMove } from "../../warehouse/useWhMove"; +import { ShortsTable } from "./ShortsTable"; +import { RELEASE_REFUSED, WhBoard } from "./WhBoard"; + +/** /warehouse (the old homeWarehouse): numbers, the open shorts, the find bar and the five-column board. */ +export function WarehouseHome() { + const { me } = useAuth(); + const { run, sheet: guardSheet } = useGuard(); + const [q, setQ] = useState(""); + const [query, setQuery] = useState(""); + const [pick, setPick] = useState({ project: null, unit: null }); + const [toast, setToast] = useState(null); + const [receiving, setReceiving] = useState(null); + const projectId = pick.project?.id; + const unitId = pick.unit?.id; + + useEffect(() => { + const t = window.setTimeout(() => setQuery(q.trim()), 250); + return () => window.clearTimeout(t); + }, [q]); + + useEffect(() => { + if (!toast) return; + const t = window.setTimeout(() => setToast(null), 4000); + return () => window.clearTimeout(t); + }, [toast]); + + const [load, reload] = useLoad( + (s) => warehouseApi.board({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s), + `${query}|${projectId ?? ""}|${unitId ?? ""}`, + ); + useChanged(reload, { delayMs: 500 }); + + const onError = useCallback( + (err: unknown) => { + if (err instanceof ApiError && err.status === 409) reload(); + setToast(errorText(err)); + }, + [reload], + ); + const wh = useWhMove({ run, onDone: reload, onError }); + + const target = (p: PacketCard) => ({ id: p.id, stage: whStage(p), job: jobName(p), progress: cardProgress(p) }); + const onMove = (p: PacketCard, to: WhStage) => wh.move(target(p), to); + const onDrop = (p: PacketCard, to: BoardColumn) => { + if (to === "released") { + setToast(RELEASE_REFUSED); + return; + } + wh.move(target(p), to); + }; + + const receive = async (r: ShortRow, body: ReceiptBody): Promise => { + try { + const out = await run((g) => packetsApi.receiveShort(r.packet.id, r.item, body, g), { sub: r.packet.unit_number }); + if (out !== undefined) reload(); + return null; + } catch (err) { + if (err instanceof ApiError && err.status === 409) reload(); + return errorText(err); + } + }; + + const data = load.state === "ok" ? load.data : null; + const k = data?.kpis; + const canMove = isWarehouseUser(me?.roles ?? []); + const count = data ? (["incoming", "pulling", "hold", "pulled"] as const).reduce((n, c) => n + data.columns[c].length, 0) : null; + + return ( + <> +
+
+
Parts pull
+

Warehouse

+
+
+ + + + {load.state === "loading" ?
Loading
: null} + {load.state === "error" ?
{load.message}
: null} + + {data ? : null} + + + + {data ? : null} + + {receiving ? ( + setReceiving(null)} + onReceive={(body) => receive(receiving, body)} + /> + ) : null} + {wh.sheet} + {guardSheet} + {toast ? ( +
+ {toast} +
+ ) : null} + + ); +} diff --git a/frontend/src/screens/warehouse/WhBoard.tsx b/frontend/src/screens/warehouse/WhBoard.tsx new file mode 100644 index 0000000..10a7309 --- /dev/null +++ b/frontend/src/screens/warehouse/WhBoard.tsx @@ -0,0 +1,227 @@ +import { useRef, type KeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react"; +import { useNavigate } from "react-router"; +import type { BoardColumn, BoardResponse, PacketCard, WhStage } from "../../api/types"; +import { Card } from "../../components/Card"; +import { fmtDay } from "../../lib/format"; +import { jobName } from "../../lib/packets"; +import { cardProgress, daysSince, pillClassFor, pillText, WH_STAGES, whLine, whStage } from "../../warehouse/stages"; +import { groupByDept, WH_COLUMNS } from "./boards"; + +export const RELEASE_REFUSED = "Open the packet and use Release to supervisor. It checks heats and shorts first."; + +/** Pixels the pointer must travel before a press becomes a drag. */ +const DRAG_START = 10; + +interface CardProps { + p: PacketCard; + canMove: boolean; + busy: boolean; + onMove: (p: PacketCard, to: WhStage) => void; + onDrop: (p: PacketCard, to: BoardColumn) => void; +} + +/** A warehouse card (the old whCard): progress, dates, the line, the stage buttons, and drag to another column. */ +export function WhCard({ p, canMove, busy, onMove, onDrop }: CardProps) { + const navigate = useNavigate(); + const dragged = useRef(false); + const s = whStage(p); + const g = cardProgress(p); + const waited = daysSince(p.wh_sent_at); + const open = () => navigate(`/packets/${p.id}`); + + const onPointerDown = (e: ReactPointerEvent) => { + if (!canMove) return; + if ((e.target as HTMLElement).closest("button, a, input, select, textarea, label")) return; + if (e.pointerType === "mouse" && e.button !== 0) return; + const card = e.currentTarget; + const sx = e.clientX; + const sy = e.clientY; + let ghost: HTMLElement | null = null; + let over: HTMLElement | null = null; + const move = (ev: PointerEvent) => { + if (!ghost && Math.hypot(ev.clientX - sx, ev.clientY - sy) < DRAG_START) return; + if (!ghost) { + ghost = card.cloneNode(true) as HTMLElement; + ghost.classList.add("whghost"); + ghost.removeAttribute("id"); + ghost.setAttribute("aria-hidden", "true"); + ghost.style.width = `${card.offsetWidth}px`; + document.body.appendChild(ghost); + card.classList.add("whsrc"); + } + ev.preventDefault(); + ghost.style.left = `${ev.clientX}px`; + ghost.style.top = `${ev.clientY}px`; + const hits = typeof document.elementsFromPoint === "function" ? document.elementsFromPoint(ev.clientX, ev.clientY) : []; + const col = hits.map((x) => x.closest("[data-whcol]")).find((x): x is HTMLElement => !!x) ?? null; + if (col !== over) { + over?.classList.remove("whover"); + over = col; + if (over && over.dataset.whcol !== "released") over.classList.add("whover"); + } + }; + const up = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + window.removeEventListener("pointercancel", up); + if (!ghost) return; + ghost.remove(); + card.classList.remove("whsrc"); + over?.classList.remove("whover"); + // The click that follows a drag is not a click. + dragged.current = true; + window.setTimeout(() => { + dragged.current = false; + }, 60); + const to = over?.dataset.whcol as BoardColumn | undefined; + if (to) onDrop(p, to); + }; + window.addEventListener("pointermove", move, { passive: false }); + window.addEventListener("pointerup", up); + window.addEventListener("pointercancel", up); + }; + + const onClick = (e: MouseEvent) => { + if (dragged.current) { + dragged.current = false; + return; + } + if ((e.target as HTMLElement).closest("button, a, input, select, textarea, label")) return; + open(); + }; + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Enter" && e.target === e.currentTarget) open(); + }; + + const btn = (to: WhStage, label: string, pri = false) => ( + + ); + + return ( +
+
+ {jobName(p)} + {pillText(s)} +
+
+ {p.op ?? ""} · {p.title ?? ""} +
+
+ + Sent {p.wh_sent_at ? fmtDay(p.wh_sent_at) : "-"} + + {p.visual_rel ? ( + + Release {fmtDay(p.visual_rel)} + + ) : null} + {waited !== null && waited >= 1 ? = 3 ? "due over" : "muted"}>{waited} d waiting : null} +
+
+ + Dwg {p.drawing || "-"} + + + {g.pulled}/{g.total} lines pulled + + {g.missingHeat ? ( + + {g.missingHeat} heat{g.missingHeat === 1 ? "" : "s"} missing + + ) : null} + {g.shorts ? {g.shorts} short : null} +
+
+ +
+
{whLine(p)}
+ {canMove ? ( +
+ {s === "incoming" ? ( + <> + {btn("pulling", "Start pulling", true)} + {btn("hold", "Hold")} + + ) : s === "pulling" ? ( + <> + {btn("pulled", "Mark pulled", g.done)} + {btn("hold", "Hold")} + + ) : s === "hold" ? ( + btn("pulling", "Resume pulling", true) + ) : ( + <> + + {btn("pulling", "Back to pulling")} + + )} +
+ ) : null} +
+ ); +} + +/** The five columns of the warehouse board. The four stage columns group by department. */ +export function WhBoard({ + columns, + canMove, + busy, + onMove, + onDrop, +}: { + columns: BoardResponse["columns"]; + canMove: boolean; + busy: boolean; + onMove: (p: PacketCard, to: WhStage) => void; + onDrop: (p: PacketCard, to: BoardColumn) => void; +}) { + return ( +
+ {WH_COLUMNS.map((c) => { + const list = columns[c.key]; + const id = `whcol-${c.key}`; + return ( +
+
+

{c.title}

+ + {list.length} + +
+ {!list.length ? ( +
{c.empty}
+ ) : c.key === "released" ? ( + list.map((p) => ) + ) : ( + groupByDept(list).map((grp) => ( +
+
+ {grp.dept.name} + {grp.cards.length} +
+ {grp.cards.map((p) => ( + + ))} +
+ )) + )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/screens/warehouse/boards.ts b/frontend/src/screens/warehouse/boards.ts new file mode 100644 index 0000000..c64babc --- /dev/null +++ b/frontend/src/screens/warehouse/boards.ts @@ -0,0 +1,33 @@ +// The warehouse board columns, grouped by department (the old whColumn). +import type { BoardColumn, DeptRef, PacketCard } from "../../api/types"; +import { whOrder } from "../../warehouse/stages"; + +export interface WhColumnDef { + key: BoardColumn; + title: string; + empty: string; +} + +export const WH_COLUMNS: readonly WhColumnDef[] = [ + { key: "incoming", title: "Waiting to pull", empty: "Nothing waiting. Clerical sends packets here when they're printed." }, + { key: "pulling", title: "Being pulled", empty: "Nobody is pulling right now. Drag a card here or tap Start pulling." }, + { key: "hold", title: "On hold", empty: "Nothing on hold." }, + { key: "pulled", title: "Pulled, ready for production", empty: "Nothing pulled and waiting on production." }, + { key: "released", title: "Released to shop (3 days)", empty: "Nothing released in the last 3 days." }, +]; + +export interface DeptGroup { + dept: DeptRef; + cards: PacketCard[]; +} + +/** Cards by department, oldest first in each; the department with the oldest packet first. */ +export function groupByDept(cards: readonly PacketCard[]): DeptGroup[] { + const groups = new Map(); + for (const p of [...cards].sort(whOrder)) { + const g = groups.get(p.dept.code); + if (g) g.cards.push(p); + else groups.set(p.dept.code, { dept: p.dept, cards: [p] }); + } + return [...groups.values()].sort((a, b) => whOrder(a.cards[0] as PacketCard, b.cards[0] as PacketCard)); +} diff --git a/frontend/src/theme/theme.css b/frontend/src/theme/theme.css index d646500..c27b039 100644 --- a/frontend/src/theme/theme.css +++ b/frontend/src/theme/theme.css @@ -350,3 +350,57 @@ label.btn input.sr { pointer-events: none; } .pdfv-pages { flex-direction: row; flex-wrap: wrap; } .pdfv-pages button { width: auto; } } + +/* ===== New in the rebuild (phase 4) ===== */ +/* The warehouse board, the stage pill and line, the pull table. From the old .wh-*, .whline, .whacts, .whcard, + .whboard, .whcol, .whover, .whsrc, .whghost, .whstep, .whgrp, .whdates, .visbadge, .heat and the pullTable rows. */ +.col-h h3 { font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; } +.wh-in { color: #d9b25e; border-style: dashed; } +.wh-pull { color: var(--amber); } +.wh-hold { color: var(--pink); } +.wh-done { color: var(--green); } +.whline { font-size: 12.5px; font-weight: 600; padding: 4px 8px; border-radius: 6px; background: rgba(255, 255, 255, .04); border-left: 3px solid currentColor; } +.whline.wh-in { border-left-style: dashed; } +.whacts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; } +.whcard { cursor: pointer; } +.whcard:hover, .whcard:focus-visible { border-color: var(--cyan); } +.whcard.whdrag { touch-action: pan-y; user-select: none; } +.whboard { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); } +.whcol { transition: border-color .12s, background .12s; } +.whcol.whover { border-color: var(--cyan); background: #12303a; } +.whsrc { opacity: .35; } +.whghost { position: fixed; z-index: 80; pointer-events: none; transform: translate(-50%, -30%) rotate(1.5deg); box-shadow: 0 10px 30px rgba(0, 0, 0, .5); opacity: .95; } +.whstep { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; } +.whgrpbox { display: flex; flex-direction: column; gap: 10px; } +.whgrp { display: flex; justify-content: space-between; align-items: center; margin: 10px 2px 0; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--cyan); } +.whgrpbox:first-of-type .whgrp { margin-top: 2px; } +.whgrp b { color: var(--muted); } +.whdates { display: flex; gap: 10px; flex-wrap: wrap; color: var(--muted); } +.whdates b { color: var(--body); } +.visbadge { display: inline-block; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; padding: 2px 7px; border-radius: 5px; border: 1px solid var(--cyan); color: var(--cyan); margin-right: 6px; } +.heat { font-family: var(--f-mono); font-weight: 600; color: var(--amber); } +.heat.none { color: var(--muted); font-weight: 400; } +input.in.heatIn { font-family: var(--f-mono); min-width: 170px; } +input.in.heatIn.need { border-color: var(--amber); box-shadow: 0 0 0 1px var(--amber) inset; } +input.in.w80 { width: 80px; } +input.in.w110 { width: 110px; } +table.pull td.need { font-size: 18px; font-weight: 600; } +tr.pullrow.pulled td { background: #13261d; } +tr.pullrow.short td { background: #2a1a24; } +tr.pullerr td { border-top: 0; padding-top: 0; } +.pullck { display: flex; gap: 8px; align-items: center; } +.pullck input { width: 26px; height: 26px; accent-color: var(--green); } +.nack { display: inline-flex; gap: 6px; align-items: center; white-space: nowrap; } +.nack input { width: 20px; height: 20px; } +td.heatcell { min-width: 190px; } +td.shortcell { min-width: 150px; } +.stack.tight { gap: 4px; align-items: flex-start; } +.shortchip { display: inline-flex; flex-wrap: wrap; gap: 4px; align-items: center; } +td.shortqty { font-size: 18px; font-weight: 700; color: var(--pink); } +td.late { color: var(--red); } +.btn.dim { opacity: .55; pointer-events: none; } +.btn.pinktxt { color: var(--pink); } +.mtrframe { width: 100%; height: min(75vh, 900px); border: 1px solid var(--line); border-radius: 10px; background: var(--white); } +.mtrimg { max-width: 100%; height: auto; border-radius: 10px; background: var(--white); align-self: center; } +.bar.usage { position: relative; height: 12px; border-radius: 6px; } +.bar.usage .mark { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--amber); } diff --git a/frontend/src/warehouse/HoldSheet.tsx b/frontend/src/warehouse/HoldSheet.tsx new file mode 100644 index 0000000..e45681b --- /dev/null +++ b/frontend/src/warehouse/HoldSheet.tsx @@ -0,0 +1,91 @@ +import { useState, type FormEvent } from "react"; +import { Sheet } from "../components/Sheet"; +import { HOLD_NOTE_MAX, WH_REASONS } from "./stages"; + +/** Put a packet on hold: one of the hold reasons and a note. "Other" needs the note. */ +export function HoldSheet({ + sub, + onHold, + onCancel, +}: { + sub?: string | undefined; + onHold: (reason: string, note: string) => void; + onCancel: () => void; +}) { + const [reason, setReason] = useState(WH_REASONS[0]); + const [note, setNote] = useState(""); + const [error, setError] = useState(null); + + const submit = (e: FormEvent) => { + e.preventDefault(); + const n = note.trim().slice(0, HOLD_NOTE_MAX); + if (reason === "Other" && !n) { + setError("Say what it's waiting on."); + return; + } + onHold(reason, n); + }; + + return ( + +
Everyone who follows this packet sees it's on hold and why.
+
+
+ {WH_REASONS.map((r) => ( + + ))} +
+ + {error ? ( +
+ {error} +
+ ) : null} +
+ + +
+
+
+ ); +} + +/** A yes or no question in a sheet. */ +export function ConfirmSheet({ + title, + sub, + text, + okLabel, + onOk, + onCancel, +}: { + title: string; + sub?: string | undefined; + text: string; + okLabel: string; + onOk: () => void; + onCancel: () => void; +}) { + return ( + +
{text}
+
+ + +
+
+ ); +} diff --git a/frontend/src/warehouse/MtrViewer.tsx b/frontend/src/warehouse/MtrViewer.tsx new file mode 100644 index 0000000..439eab0 --- /dev/null +++ b/frontend/src/warehouse/MtrViewer.tsx @@ -0,0 +1,29 @@ +import { assetsApi, blobUrl } from "../api/assets"; +import { Sheet } from "../components/Sheet"; +import { useLoad } from "../lib/useLoad"; + +/** Shows an MTR file: a PDF in a frame, an image as itself, and a link to open it in a new tab. */ +export function MtrViewer({ assetId, title, onClose }: { assetId: string; title: string; onClose: () => void }) { + const [meta] = useLoad((s) => assetsApi.get(assetId, s), assetId); + const url = blobUrl(assetId); + const mime = meta.state === "ok" ? meta.data.mime : null; + return ( + +
+ + Open in new tab + + + +
+ {meta.state === "loading" ?
Loading
: null} + {mime?.startsWith("image/") ? ( + {title} + ) : meta.state !== "loading" ? ( +