diff --git a/CHANGELOG.md b/CHANGELOG.md index f5fa89a..966ad4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to this project are recorded here. The format follows [Keep ### Added +- Phase 2: core domain. Migration 0003 with projects, project aliases and renumber history, units, packets and every child table (work orders, pages, BOM lines, shorts and receipts, welds, heat tags, heat table placements, stages, warehouse stage rows, transitions, Q.C. inspections and history, seals, packet log, VISUAL state), change orders with items, required departments and verifications, assets, packet documents, Q.C. documents, ITPs, user station limits and the department weld flag. Append-only triggers, soft-delete columns with partial unique indexes, server-assigned counters on packets. +- The policy engine in Python (`server/packetstation/policy/`): Principal, PacketState, the Action enum, one rule per action, custody, named preconditions, the change-order hold, and the order of checks from the plan. Every packet read returns `can`, `blocked` with reasons, and `holder`. docs/POLICY.md lists the rules and the tightenings against the old build for Seth. +- The write pipeline: one transaction per write with the packet row locked, the policy decision, compare-and-set on the expected status and department (409 "someone else already took this step" with the current state), If-Match row versions on child rows, server-assigned weld, heat tag, page and change order numbers, field-level compare-and-set on header scalars, packet version bump, packet log and audit rows, NOTIFY. Signatures through the phase 1 helper on every action the plan marks. Admin edits need an IT reason and are marked in the packet log and the audit. Filed packets are locked; filing writes an HMAC seal and Admin reopen breaks it with a reason. +- The packet API under `/api/packets`: list for the boards with the basic search, read, hand-made draft, header edit, transitions, work orders, pages, documents, move, remove, closed in VISUAL, data book date, reopen, seal, log, BOM, shorts and receipts, welds, heat tags, heat table, stage fields, checklist, clock, warehouse stage, Q.C. inspection, change orders (issue, approve, reject, hold, verify). +- `/api/projects` and `/api/units`: create, edit with field compare-and-set, units one or several at a time, renumber with aliases and history, soft delete with a reason, number lookup through aliases. +- `/api/assets` upload (type by content, 25 MB, sha256, stored by id under ASSETS_DIR, PDF page count) and `/_blob/{id}` served with a session, Content-Disposition and nosniff. +- Input cleaning and limits on the server (`text.py`): the old cleanText rules and per-field lengths; bodies over 200 KB refused. +- Frontend: the home layout (headline, KPIs, attention, find bar with the basic search and the two-step project and unit picker, boards and list view), the Clerical screen (Changes to approve, Flagged, Going to the warehouse, Ready to close, Ready for data book, Filed; projects and units with new project and unit sheets, renumbering, move packet), the packet page (header, Drawing tab with pdf.js, Materials, Work orders with QR codes, Documents, History) with every control driven by `can` and `blocked`, hand-made draft creation, the heat map question, send to warehouse, send straight to a department or Q.C., signature and IT reason sheets, live refresh from server events. +- Tests: the full policy matrix (every transition as every role in every status), custody, short-parts and change-order exceptions, preconditions, compare-and-set conflicts and row versions per table, server-assigned numbers under concurrent inserts, the audit and packet log rows, projects and units, assets. A Playwright scenario for the Clerical draft flow against the CI stack. +- `/api/departments` now returns station ids and the weld flag. - Phase 1: identity. Sign-in with the domain account and password against PESCO's Active Directory over LDAPS (search with the service account, then bind as the user; nested groups resolved; disabled accounts refused). Roles are the union of a person's `PS-*` groups, mapped by a table derived from the department list. - Server-side sessions: HttpOnly, SameSite=Strict cookie, idle sign-out after 15 minutes, absolute 12 hours for password sessions and 10 hours for PIN sessions, server-recorded lock after 3 idle minutes or a tab hidden for over a minute, step-up unlock with PIN or password. Origin and `X-Requested-With` check on every state-changing request. - Optional Packet Station PIN, 6 to 12 digits, set by the user with their domain password, hashed with Argon2id. PIN sign-in by employee ID on registered devices only. Five wrong attempts disable the PIN; three wrong attempts at unlock or signature end the session. diff --git a/README.md b/README.md index 7701032..3c9aab1 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,18 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo ## Status -**Rebuild in progress. Phase 1 done: identity, sessions, audit, frontend shell.** +**Rebuild in progress. Phase 2 done: core domain, policy engine, packet API, Clerical screen.** -People can sign in; no packet feature exists yet. 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. 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:. | Area | Exists yet? | |---|---| | Repo, docs, CLAUDE.md | Yes (phase 0) | | Image, compose, CI, `doctor`, migration runner, security headers, build version endpoint | Yes (phase 0, image and stack verified on docker-1) | | 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) | -| Packets, policy, Clerical screens | No (phase 2) | +| 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 and drawings | No (phase 3) | -| Warehouse, shop, Q.C., close and file, PDFs | No (phases 4 to 7) | +| Warehouse, shop, Q.C., close and file, PDFs | API only (phase 2); screens in phases 4 to 7 | | Project Management, Admin, notifications, backups | No (phases 8 and 9) | | Regression bench and go-live | No (phase 10) | @@ -76,6 +76,8 @@ docker compose -p packetstation-test --env-file /etc/packetstation/test/stack.en -f deploy/compose.yaml -f deploy/compose.proxy.yaml up -d ``` +Phase 2 adds `/api/projects`, `/api/units`, `/api/packets` (header, transitions, work orders, pages, documents, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), `/api/assets` and `/_blob/{id}`. Every packet read carries `can`, `blocked` and `holder` from the policy. See [docs/POLICY.md](docs/POLICY.md) and [docs/SCHEMA.md](docs/SCHEMA.md). + The `packetstation` command has `doctor`, `migrate`, `app`, `worker`, `ad-groups --list` (the 50 AD group names the app expects) and `ad-refresh` (create or refresh the users from the groups' members). CI stack with the Samba domain controller and the Playwright scenario, on a Docker host: @@ -96,8 +98,8 @@ Server tests that need PostgreSQL run when `TEST_PG_SUPERUSER_URL` is set; the o - [docs/SECURITY.md](docs/SECURITY.md): old layers and where they live now, the new controls, the requirement ID table. - [docs/DEPLOY.md](docs/DEPLOY.md): host, stacks, secrets, NPM proxy host, doctor, backups. - [docs/FEED.md](docs/FEED.md): the VISUAL feed and drawing jobs (phase 3). -- [docs/POLICY.md](docs/POLICY.md): who may do what (phase 2). -- [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (phases 1 to 3). +- [docs/POLICY.md](docs/POLICY.md): who may do what, the preconditions, the order of checks, concurrency, and the tightenings against the old build. +- [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (phases 1 and 2; phase 3 adds the caches). - [docs/GO-LIVE.md](docs/GO-LIVE.md): go-live procedure (phase 10). - [docs/PAPER-FALLBACK.md](docs/PAPER-FALLBACK.md): what the shop does when the server is down (phase 10). - [CHANGELOG.md](CHANGELOG.md): what changed. diff --git a/docs/POLICY.md b/docs/POLICY.md index 8758fa5..9683d14 100644 --- a/docs/POLICY.md +++ b/docs/POLICY.md @@ -1,39 +1,219 @@ # Packet Station policy -Status: planned, phase 2. This is a stub. It lists what the approved plan says this document will contain. See [PLAN.md](PLAN.md), "Policy port and concurrency rules". +Who may do what to a packet, and when. This file is written from the code in `server/packetstation/policy/`. When they disagree, the code is the rule and this file is out of date. ## Purpose -Who may do what to a packet, and when. A Python port of the old `guard.js`, run only on the server. Pure functions, no I/O. +The policy is a Python port of the old `guard.js`. It runs only on the server. Every packet write calls `policy.check()` inside the write transaction, under the packet row lock. Every packet read returns `can`, `blocked` and `holder` from the same rules, so the screens only show what the server will accept. + +The package is pure functions with no I/O: + +- `actions.py`: the `Action` enum, the `Who` predicates, the `Rule` record and the `RULES` table. +- `principal.py`: `Principal`, `PacketState`, `principal_from_roles()`. +- `custody.py`: `holds()`, `works_on()`, `holder_name()` and the Who predicates. +- `preconditions.py`: the named preconditions and their messages. +- `engine.py`: `check()`, `evaluate()`, `can()` and `Decision`. ## Roles -Every role comes from an AD group: Admin; Clerical, Warehouse and Q.C. with a member and a Supervisor level; Project Management (view only); and 14 shop departments with Worker, Lead and Supervisor. A person's roles are the union of their groups. +Every role comes from an AD group (see docs/SECURITY.md). The role strings are `admin`, `clerical`, `clerical_supervisor`, `warehouse`, `warehouse_supervisor`, `qc`, `qc_supervisor`, `pm` and `shop::`. A person's roles are the union of their groups. + +`principal_from_roles(user_id, roles, station_limits=(), session_kind="password")` builds the policy's `Principal`: + +| Field | Meaning | +|---|---| +| `admin` | In PS-Admins. Admin is not membership. It is the bypass rule below. | +| `support` | The codes among CL, WH and Q.C. the person belongs to, at any level. A supervisor role implies membership. | +| `support_sup` | The codes among CL, WH and Q.C. where the person is Supervisor. | +| `shop` | Shop department code to level: 1 worker, 2 lead, 3 supervisor. The highest level wins. | +| `station_limits` | Station ids a worker is limited to. Empty means any station. | +| `pm` | Project Management. PM can read every packet and change nothing. | +| `session_kind` | `password` or `pin`. | +| `system` | `visual-feed` for the worker. Never set from a session. | + +A role string that does not parse is ignored. ## Custody -Only whoever holds the packet may change it. Draft: Clerical. Warehouse: Warehouse. Ready and review: the department lead or above. Assigned and in progress: the lead or above, or the assigned worker. Q.C.: Q.C. Complete: Clerical. Filed: nobody. +Only whoever holds the packet may change it. Everyone else can open it and follow it. -## Actions and transitions +| Status | Holder | +|---|---| +| draft | Clerical | +| warehouse | Warehouse | +| ready, review | Lead or supervisor of the packet's department | +| assigned, in_progress | Lead or supervisor of the department, or a worker who works on it | +| qc | Q.C. | +| complete | Clerical (data book) | +| filed | Nobody | -An Action enum with one rule per action: who, from and to status, department change, change-order hold, preconditions, signature meaning, and whether a reason is required. +`works_on(principal, state)`: a member of the packet's department. Leads and supervisors always pass. A worker passes when they have no station limits, or the limits hold the packet's station. START and SUBMIT also need the assigned welder for a worker (see the tightenings). + +`holder_name(state, dept_name, station_name)` gives the text for messages: "Clerical", "Warehouse", "Q.C.", "Clerical (data book)", "the data book (filed)", else the department name, plus " · " while the packet is at a station. + +Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, review, qc, complete, filed. SHOP is ready, assigned, in_progress and review. ACTIVE is assigned and in_progress. + +## Who + +| Who | Passes when | +|---|---| +| CL, WH, QC | A member of that support department, any level | +| CL_SUP, WH_SUP, QC_SUP | Supervisor of that support department | +| CL_OR_QC | A member of Clerical or Q.C. | +| HOLDER | Holds the packet (custody above) | +| DEPT_LEAD | Lead or supervisor in the packet's department | +| DEPT_SUP | Supervisor in the packet's department | +| WORKS_ON | Lead or above in the department, or a worker in the department who is the assigned welder and whose station limits allow the station | +| CO_VERIFIER | Supervisor of the department named in the request: a shop Supervisor for a shop code, WH or Q.C. Supervisor for WH or QC. Clerical never verifies. | +| ADMIN | In PS-Admins | +| SYSTEM | The worker (`system` is set) | + +## Rule table + +`from` is the set of statuses the action starts from. "any" means any status, with the filed lock applied separately. `co hold` means an open change order holds the step. + +| Action | Who | From | To | Dept | CO hold | Preconditions | Signature | Reason | +|---|---|---|---|---|---|---|---|---| +| send_to_warehouse | CL | draft | warehouse | no | | heat_map_answered, drawing_attached | | | +| release_to_shop | CL | draft | ready | allowed | | heat_map_answered, drawing_attached, target_dept_given | | | +| 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 | | | +| 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 | | | | | +| pull_back | DEPT_LEAD | in_progress | ready | no | | | | | +| start | WORKS_ON | assigned | in_progress | no | | | | | +| submit | WORKS_ON | assigned, in_progress | review | no | yes | checklist_complete, welds_recorded, filler_set | Submit work to the supervisor | | +| return_to_station | DEPT_SUP | review | assigned | no | | reason_given | | yes | +| send_to_qc | DEPT_SUP | review | qc | no | yes | qc_book_spool_location | Send to Q.C. | | +| route_from_review | DEPT_SUP | review | ready | required | yes | target_dept_given | Route to another department | | +| qc_reject | QC | qc | review | no | | reason_given | Q.C. reject | yes | +| qc_accept | QC | qc | complete | no | yes | qc_missing_empty, route_given | Q.C. accept | | +| qc_accept_and_route | QC | qc | ready | required | yes | qc_missing_empty, target_dept_given | Q.C. accept and route | | +| file | CL | complete | filed | no | yes | vclosed_if_databook | File in the data book | | +| admin_reopen | ADMIN | filed (filed_ok) | complete | no | | reason_given | Reopen a filed packet | yes | +| edit_header | CL | draft | | | | | | | +| edit_drawing_ref | CL | any | | | | | | | +| edit_dept | CL | draft | | | | | | | +| move_unit | CL | any | | | | | | | +| edit_wos | CL | draft | | | | | | | +| edit_bom | HOLDER | draft, warehouse, SHOP | | | | | | | +| wh_receive_short | WH | any | | | | short_open | | | +| edit_heats | HOLDER | warehouse, SHOP | | | | | | | +| edit_heat_table | HOLDER | warehouse, SHOP | | | | | | | +| edit_welds | HOLDER | SHOP | | | | | | | +| edit_checklist | HOLDER | SHOP | | | | | | | +| edit_stage_fields | HOLDER | SHOP | | | | | | | +| clock | HOLDER | SHOP | | | | | | | +| edit_qc | QC | qc | | | | | | | +| set_wh_stage | WH | warehouse | | | | | | | +| edit_document_set | CL | any | | | | | Attach a drawing by hand (attach only) | | +| doc_add | CL_OR_QC | any | | | | | | | +| doc_remove | CL_OR_QC | any | | | | | | | +| rev_approve | CL_SUP | draft | | | | reason_given | Approve the M-Files revision | yes | +| set_vclosed | CL | complete, only when routed_to is databook | | | | | | | +| set_databook_at | CL_OR_QC | any (filed_ok) | | | | | | | +| remove | CL | draft | | | | reason_given | | yes | +| co_issue | CL | any | | | | | Issue a change order | | +| co_approve | CL | any | | | | | Approve a change order | | +| co_reject | CL | any | | | | reason_given | Reject a change order | yes | +| co_hold_toggle | CL | any | | | | | | | +| co_verify | CO_VERIFIER | any | | | | | Verify a change order for the department | | +| itp_edit_line | QC | any | | | | | | | +| itp_block_sign | QC | any | | | | | Sign an ITP block | | +| itp_sign_off | QC | any | | | | | Sign off the ITP | | +| itp_reopen | QC_SUP (Admin through the bypass) | any | | | | reason_given | | yes | +| vis_update_draft | SYSTEM | draft | | | | | | | +| vis_retire_draft | SYSTEM | draft | | | | | | | +| vis_open_co | SYSTEM | any but draft and filed | | | | | | | +| vis_auto_release | SYSTEM | draft | warehouse | | | heat_map_answered, drawing_attached | | | + +The `vis_*` actions are system only. People can never do them, and the worker can do nothing else. ## Preconditions -Named predicates, checked on the server and never bypassed, including by Admin. +Each precondition is a named predicate over the packet state and the request input. They are never bypassed, Admin included. The first one that fails gives the message. + +| Name | Passes when | Message | +|---|---|---| +| 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. | +| 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. | +| welds_recorded | at least one weld, in a weld department | Record at least one weld first. | +| filler_set | filler metal entered, in a weld department | Enter the filler metal first. | +| qc_book_spool_location (input) | book and spool confirmed and a location given | Confirm the book and the spool, and give the location. | +| qc_missing_empty | the Q.C. record has nothing missing | Q.C. still needs: (the list). | +| reason_given (input) | a reason of at least 8 characters after cleaning | Give a reason of at least 8 characters. | +| vclosed_if_databook | routed to Shipping, or closed in VISUAL | Close the job in VISUAL first. | +| co_staged_if_dwg_change (input) | no drawing change, or the new drawing is staged (phase 3) | Stage the new drawing for the change order first. | +| route_given (input) | routed_to is databook or shipping | Choose Data Book or Shipping. | +| target_dept_given (input) | a shop department different from the packet's; from a draft any shop department | Choose a department. / Choose a different department. | + +"(input)" marks a precondition that reads request input, not packet state. `can()` skips those. ## Order of checks -Removed, wrong status, change-order hold, admin bypass of custody with an IT reason, filed lock, who and custody, then preconditions. +`check(principal, state, action, *, extra={}, it_reason=None) -> Decision` runs these in this order and stops at the first failure: + +1. **Removed.** A removed packet refuses everything, Admin included. Code `removed`. +2. **Status.** The status must be in the rule's from set. RETURN_TO_CLERICAL also needs wh_skipped. SET_VCLOSED also needs routed_to databook. An unknown status fails here too. Code `wrong_status`. +3. **Change-order hold.** A held action refuses while an approved change order waits for verification, or a pending one is on hold. Admin included. Code `co_hold`, with the hold text. +4. **System.** A system-only action refuses every person (`system_only`). The system principal passes only system-only actions (`forbidden`). +5. **Admin bypass.** An admin who is not allowed by the rule's Who passes who and custody when an IT reason of at least 8 characters is given. Without one the code is `it_reason_required`. SET_DATABOOK_AT needs no reason. The decision is marked `admin_bypass`, and the write marks the log row as an IT edit. +6. **Filed lock.** A filed packet refuses everything except SET_DATABOOK_AT and ADMIN_REOPEN, Admin included. Code `filed`: "This packet is filed and locked. IT can reopen it if a correction is needed." +7. **Who and custody.** Code `forbidden`. For a step: "Your access doesn't allow this step." For an edit: "Your access doesn't allow changing (what) on this packet. It's with (holder) right now." PM fails here for everything. +8. **Preconditions,** in rule order, never bypassed. Code `precondition` with the message, or `reason_required` when the missing piece is the rule's reason, so the screen can ask for one. + +`Decision` carries `ok`, `code`, `why`, `admin_bypass`, `signature` (the meaning text when the rule needs one) and, in read mode only, `reasons`. + +The packet API answers 403 with the code for most failures, and 400 for `it_reason_required` and `reason_required`. ## Signatures -Which actions need a PIN or domain password signature, and what the signature records. +These actions need a signature: submit, send_to_qc, route_from_review, qc_reject, qc_accept, qc_accept_and_route, file, admin_reopen, edit_document_set (attach only), rev_approve, co_issue, co_approve, co_reject, co_verify, itp_block_sign and itp_sign_off. The rule's signature text is the meaning that is signed. + +The request carries `sign: {factor, secret}`. The write pipeline calls the phase 1 helper `packetstation.auth.signatures.sign()` inside the same transaction, after the policy passes. It checks the PIN or domain password against the session's own user. A PIN works in any session. It logs the attempt and applies the five-strike PIN disable and the three-strike session end. It writes an append-only `signatures` row with the user, session, account, display name, session kind, factor, action, meaning, packet, subject and a sha256 over the canonical JSON of what was signed (the action, the packet id, the expected state and the request fields). The `packet_transitions` and `packet_log` rows point at it. ## Concurrency -Compare-and-set on workflow steps (409 "someone else already took this step"), row versions on child rows, server-assigned numbers, append-only tables. +- **Workflow steps** are compare-and-set on `expect: {status, dept}` under the packet row lock (`SELECT ... FOR UPDATE`). When the packet moved on, the answer is 409 `stale_step` "Someone else already took this step." with the current status, department and version. There is no packet version check on a step, so a weld edit never blocks a send. Side effects run in the same transaction. +- **Keyed child rows** (BOM lines, Q.C. inspections, stage fields, welds, heat tags) take `If-Match: `. A mismatch is 409 `row_conflict` with the current row. +- **Numbers** for welds, heat tags, pages and change orders come from counters on the packet row under the row lock, so concurrent adds never collide. +- **Header scalars** use field-level compare-and-set: the body sends `set` and `base`. A key whose base differs from the current value is 409 `field_conflict` with the current value. A key with no base is written as is. +- **Work orders** are a draft-only full replace with If-Match on the packet version. +- **Shorts and change-order actions** are state compare-and-set. Checklist keys are written one key at a time. The heat table position is an idempotent upsert. A change-order verification is an insert, so a second one is "already verified". +- **Append-only tables** never conflict: packet_transitions, packet_log, qc_history, short_receipts, project_renumbers, itp_rejects and co_verifications. Triggers refuse UPDATE, DELETE and TRUNCATE, and the app role has no grant for them. + +## Reads: can, blocked and holder + +Every packet read returns: + +- `can`: the actions that pass now. Input-only preconditions are skipped, because they are what the user gives with the step. +- `blocked`: the actions that pass who and custody but fail a state precondition or the change-order hold, with every reason. The screen shows the control disabled with the reason. +- `holder`: `holder_name()` for the packet. + +`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. ## Tightenings against the old build -The old policy is looser than the old UI in places. Each tightening is listed here so Seth can veto it. Known so far: Clerical lead actions move to Clerical Supervisor; packets routed to Shipping get the Clerical close and file path; SET_DATABOOK_AT is restricted to Clerical and Q.C. by default. +The old policy was looser than the old screens in places. The rebuild follows the screens where they were stricter, and the README where it states the intent. Each row is a change Seth can veto. + +| What the old build allowed | What the rebuild does | Why | Can be vetoed | +|---|---|---|---| +| A department lead could write welds, BOM, heat tags, the checklist, stage fields and the clock through the policy whenever the packet was in a shop status. | These edits need the holder of the packet (the holder rule, applied as the old screens did). A lead of another department cannot. | The screens only offered these to the holder. The policy now matches. | Yes | +| Any worker in the department within their station limits could start or submit a packet (`worksOn` ignored who was assigned). | START and SUBMIT need the assigned welder for a worker. Leads and supervisors still can. | The README says only the assigned worker. | Yes | +| A status change could carry any other field in the same save. | A transition carries only its own fields (the step's inputs). Other changes go through their own edit actions and rules. | One rule per write keeps the check honest. | No | +| Clerical "lead" actions (approve an M-Files rev mismatch, move back from VISUAL review) were open to any Clerical member with a lead flag. | REV_APPROVE and moving back from VISUAL review need Clerical Supervisor (PS-Clerical-Supervisor). | There is no Clerical lead AD group. | Yes | +| A packet routed to Shipping had no path to filed. | Complete packets routed to Shipping get the same Clerical close and file path. Only the Data Book route needs the VISUAL close before filing. | Shipping packets were stuck at complete. | Yes | +| 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 | +| 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 | +| Admin passed every check after the change-order hold, so an admin could make a step from any status. | Admin goes through the same status check. The bypass covers who and custody only. Preconditions still apply. | Admin fixes data, not the workflow order. | Yes | diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index e36bb6e..d523b1f 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -1,6 +1,6 @@ # Packet Station schema -Status: phase 1 built (identity and organisation seed). Phases 2 and 3 add the rest. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema". +Status: phases 1 and 2 built (identity, organisation, projects, packets, change orders, documents). Phase 3 adds the job and VISUAL tables, phase 9 the notification tables. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema". ## Conventions @@ -26,27 +26,101 @@ Append-only tables use the `ps_append_only` trigger from 0001 and revoke UPDATE, ## Organisation (phase 1 seed, phase 2 completes) -- `departments`: `code` (text PK, `CL`, `WH`, `QC`, `PM`, `01` to `14`), `slug` (the AD group short name, unique), `name`, `kind`, `sort`, `active`, `supervisor_group` (false only for PM). A trigger refuses changes to `code` and `slug`. Seeded with the 18 departments from the brief and the old `depts.js`. +- `departments`: `code` (text PK, `CL`, `WH`, `QC`, `PM`, `01` to `14`), `slug` (the AD group short name, unique), `name`, `kind`, `sort`, `active`, `supervisor_group` (false only for PM). A trigger refuses changes to `code` and `slug`. Seeded with the 18 departments from the brief and the old `depts.js`. Migration 0003 adds `weld` (bool, default false), true for 03, 04, 05, 06, 07 and 10. A weld department gets the welding checklist, and submit there needs at least one weld and a filler. - `stations`: (`dept_code`, `name`, `sort`, `active`), seeded with the old station lists (Stall 1 to 6, Station 1 to 4, Hydro Bay 1 and 2, Blast Booth, Paint Booth 1 and 2). -- `user_station_limits`: phase 2. +- `user_station_limits`: (`user_id`, `station_id`) primary key. A worker with rows here may only work on packets at those stations. No rows means any station in the department. The rows go when the user row goes. The 50 AD group names are derived from `departments` plus `PS-Admins`: `PS-` and `PS--Supervisor` for support departments, `PS---` for shop departments. `packetstation ad-groups --list` prints them. +## Enums (phase 2, migration 0003) + +Declaration order matters for `packet_status`: it is the workflow order, so `status >= 'qc'` works in SQL. + +| Enum | Values in order | +|---|---| +| `packet_status` | draft, warehouse, ready, assigned, in_progress, review, qc, complete, filed | +| `wh_stage` | incoming, pulling, hold, pulled | +| `co_review` | pending, approved, rejected | +| `co_origin` | visual, drawing, manual | +| `source_kind` | visual, hand | +| `project_status` | open, hold, closed | +| `route_kind` | databook, shipping | +| `drawing_state` | none, needed, attached, rev_mismatch, not_released, not_found, lookup_failed, hand | +| `itp_status` | open, signed, reopened | + +Smaller closed sets are text with a CHECK: `projects.source` and every `*_src` (visual, hand), `project_aliases.kind` (renumber, unit_renumber, import), `packet_pages.type` (drawing, info, overlay), `bom_lines.src` (visual, hand), `qc_inspections.result` (accepted, rejected), `itp_lines.result` (approve, reject, na). Null means unset in each. + ## Projects and units (phase 2) -projects, project_aliases, project_renumbers, units. +- `projects`: `number` (text, unique among live rows on `upper(number)`), `name`, `customer`, `po`, `due`, `status` (project_status), `notes`, `source` (hand or visual), `needs_details` (the feed made it and Clerical has not filled it in), `version` for field CAS. Order-derived values carry provenance: `customer_src`, `po_src`, `order_date_src` and `ship_date_src` say visual or hand, and `customer_visual` and `po_visual` keep what VISUAL said. The feed never overwrites a hand value. Soft delete. +- `units`: `project_id`, `lot` (text), `name`, `due`, `status`, `notes`, the same `*_src` companions, `order_date`, `ship_date`, `version`. A CHECK keeps `lot` to 1 to 3 upper-case letters or digits, and a lot of digits only is exactly 3 long (`001`, `7A`). Unique among live rows on (`project_id`, `lot`). Soft delete. +- `project_aliases`: `alias` (text PK, upper-cased by CHECK), `project_id`, `unit_id` (null for a project number), `kind` (renumber, unit_renumber, import), `created_at`. Every current and old number of every project and unit is here, so a lookup by any number resolves, and a renumber cannot reuse a number that was ever taken. +- `project_renumbers`: append-only. `at`, `by`, `project_id`, `unit_id`, `old_number`, `new_number`, `reason`. +- View `v_units`: every `units` column plus `number` (`project.number || lot`, so `L999` and `001` make `L999001`), `project_number`, `project_name`, `project_status` and `due_effective` (the unit's due, else the project's). ## Packets (phase 2) -The packet header and its child tables: work orders, pages, BOM lines, shorts, welds, heat tags, stages, warehouse, transitions, Q.C., seals, log. +### Header + +`packets` is the header. The columns that matter: + +- Placement: `unit_id`, `dept_code`, `op`, `title`, `customer`, `drawing`, `rev`, `wps`. +- Workflow: `status` (packet_status), `status_at`, `station_id`, `welder_id` and `welder_stamp` (a snapshot taken at assign), `for_sup_id`, `wh_skipped` (true when Clerical sent the draft straight to a department or to Q.C.), `wh_sent_at`, `routed_to` (route_kind, set at Q.C. accept), `return_note` (the reason shown after a send-back), `vclosed_at` and `vclosed_by`, `databook_at`. +- Preconditions: `heat_map` is nullable on purpose. Null means not answered yet, and the warehouse step needs an answer. +- Source: `source_kind` (visual or hand), `source` jsonb, `visual_key` (the feed's match key), `visual_rel`, `visual_want`, `drawing_state`, `flags` text[], `visual_fp_applied` (the fingerprint the packet matches), `visual_closed_at`, `visual` jsonb (system-written sub-keys). +- Counters: `next_weld_no`, `next_heat_no`, `next_page_no` and `next_co_no`, all starting at 1. See "Versioning". +- `version`, `created_at`, `created_by`, `updated_at`, soft delete. + +Indexes: `packets_visual_key` is unique on `visual_key` where the key is set and the row is live. A removed packet keeps its key, so two removed rows may share one, but only one live row may hold it. Also `(status)` for live rows, `(unit_id)` and `(dept_code, status)` for the boards. + +The trigger `ps_packet_touch` sets `updated_at` on every UPDATE. It does not touch `version`. + +### Children + +- `packet_work_orders`: `wo_base`, `lot`, `split`, `sub` (all text; the last three default to `''` so the key has no nulls), `seq`, `qty`, `target_hrs`, `op`, `description`, `visual_status`, `missing_runs` and `gone_at` (the feed's "work order gone" check), `sort`, `row_version`. Unique on (`packet_id`, `wo_base`, `lot`, `split`, `sub`). +- `packet_pages`: `n` (the packet page number, from `next_page_no`), `type`, `asset_id`, `pn` (page within the asset), `dwg`, `rev`, `co_id` (the change order that added it), `void_co_id`, `void_at`, `void_by`, `void_new_n` (a replaced page is voided, never deleted), `row_version`. Unique on (`packet_id`, `n`). +- `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`. +- `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. +- `heat_table_placements`: `page`, `x`, `y`, `s`, `by`, `at`. Unique on (`packet_id`, `page`), written as an upsert. +- `packet_wh`: the warehouse stage history. `stage` (wh_stage), `by`, `at`, `reason`, `note`, `auto`, `closed_at`. The current row has `closed_at` null and is unique per packet. A stage change closes it and inserts the next. +- `packet_transitions`: append-only. Every status change: `at`, `by`, `action`, `from_status`, `to_status`, `from_dept`, `to_dept`, `reason`, `signature_id`, `it_reason`. Replaces the old `hist` and `sentBack` lists. +- `qc_inspections`: `stage_id`, `sent_at`, `sent_by`, `book`, `spool`, `location`, `sent_note`, `checks` jsonb, `weld_exam` jsonb (weld_no to `{result, note}`), `nde_method`, `nde_result`, `nde_report`, `hardness`, `nace`, `inspector`, `stamp`, `notes`, `result`, `result_at`, `result_by`, `result_note`, `signature_id`, `row_version`, `closed_at`. At most one open inspection per packet. +- `qc_history`: append-only. `at`, `result`, `inspector`, `note`, `dept_code`, `snapshot` jsonb. +- `packet_seals`: the HMAC-SHA256 `hash` written at filing, `key_id`, `sealed_at`, `sealed_by`, `signature_id`, and `broken_at`, `broken_by`, `broken_reason` when IT reopens. At most one unbroken seal per packet. +- `packet_log`: append-only. The user-visible history: `at`, `by`, `by_name` and `role` (snapshots), `action`, `note`, `it_reason`, `signature_id`, `transition_id`. Indexed on (`packet_id`, `id`). +- `packet_visual_state`: one row per packet, written by the worker. `legs` jsonb, `ticket_hours`, `order_ref`, `observed_at`. + +Every who column references `users (id)`. Every `signature_id` references `signatures (id)`. ## Change orders (phases 2 and 3) -change_orders, co_items, co_required_depts, co_verifications. +- `change_orders`: `co_no` (from `next_co_no`; unique per packet), `origin` (co_origin), `review` (co_review), `minor`, `hold`, `held_by`, `held_at`, `ecn`, `reason`, `issued_*`, `approved_*` and `rejected_*` (each with a `*_signature_id`), `vis`, `waiting` and `staged` jsonb (feed-shaped), `target_fp`, `row_version`. At most one pending VISUAL change order per packet (unique on `packet_id` where origin is visual and review is pending), so the feed re-aims it instead of stacking new ones. +- `co_items`: the page changes. `old_page`, `old_dwg`, `old_rev`, `new_page`, `new_dwg`, `new_rev`, `added`. +- `co_required_depts`: (`co_id`, `dept_code`) primary key. The departments that must verify. +- `co_verifications`: append-only, keyed on (`co_id`, `dept_code`). `by` and `signature_id` are required, plus `at`, `note`, `it_reason`. A primary key violation means the department already verified. A verification cannot be withdrawn. +- View `v_open_cos` ports the old `openCOs()`: `packet_id`, `co_id`, `co_no`, `review`, `hold`, `pending_depts` (text[], the required departments without a verification, sorted by code). A row appears when review is approved and `pending_depts` is not empty, or when review is pending and `hold` is true. A pending change order without hold holds nothing. A rejected one never appears. The policy's CO hold and the boards' open-CO count read this view. ## Documents (phases 2, 4 and 6) -assets, packet_docs, qc_docs, ITPs. +- `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). +- `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`. +- `itp_lines`: `key` (unique per ITP), `result`, `by`, `at`, `ncr`, `note`, `signature_id`, `row_version`. +- `itp_rejects`: append-only. `line_key`, `ncr` (required), `note`, `at`, `by`. Every reject keeps its NCR id, even after the line is approved later. +- `itp_blocks`: `block_key` (unique per ITP), `signed_at`, `signed_by`, `signature_id`. + +## Append-only set + +These tables have the `ps_append_only` row and TRUNCATE triggers, and `ps_app` holds only SELECT and INSERT on them: + +`audit_events`, `signin_attempts`, `signatures`, `project_renumbers`, `short_receipts`, `packet_transitions`, `qc_history`, `packet_log`, `itp_rejects`, `co_verifications`. + +The app role is refused by privilege first. The migrate role owns the tables, so the trigger refuses it. The views `v_units` and `v_open_cos` are SELECT only for `ps_app`. ## Audit, settings, jobs (phases 1 and 3) @@ -62,4 +136,8 @@ visual_batches, visual_rows, visual_materials, order links, labor, change rows, ## Versioning -packets.version goes up in every transaction that touches the packet or a child. It is the ETag and the NOTIFY payload. +- `packets.version` goes up by one in every write transaction that touches the packet or any of its children. The write pipeline does it explicitly (`UPDATE packets SET version = version + 1 ... RETURNING version`) after the caller's SQL. No trigger bumps it, so a read never does. It is the packet ETag and the NOTIFY payload. +- `projects.version`, `units.version` and `itps.version` play the same part for those rows. +- Child rows that are edited in place carry `row_version`. A PATCH sends `If-Match: `. A mismatch is 409 `row_conflict` with the current row. The write bumps `row_version`. +- Every write takes `SELECT ... FOR UPDATE` on the packet row first. That lock replaces the old 8-second lease and serialises the counters: `next_weld_no`, `next_heat_no`, `next_page_no` and `next_co_no` hand out the next number with `UPDATE packets SET next_weld_no = next_weld_no + 1 RETURNING next_weld_no - 1`, so two concurrent adds never get the same number. +- Transitions do not check `version`. They compare and set on `expect: {status, dept}`, so a weld edit never blocks a send. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c8f8f6b..0274657 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 1. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) are built and tested against a Samba domain controller in CI. Every other control names the phase that builds it. +Status: phase 2. 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. Every other control names the phase that builds it. Contents: @@ -25,7 +25,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro | 7 | Record integrity check | A worker job (seals, asset hashes, audit hash chain) with an alert. | 9 | | 8 | Self-hosted libraries | Kept: no CDN. The CSP allows nothing external. | 0 | | 9 | Output escaping | React escapes output by default. The PDF export escapes on the server. | 1, 7 | -| 10 | Central permission policy | Server policy, checked on every write, including writes that do not come from the screens. | 2 | +| 10 | Central permission policy | Server policy, checked on every write, including writes that do not come from the screens. Built. | 2 | | 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 | @@ -71,9 +71,15 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro ### Policy (phase 2) -- One policy in Python on the server. Every write goes: session, policy, transition, validation, version check, audit row, in one transaction. -- Every packet read returns `can: [...]` and `blocked: {...}` so the browser enables controls from the server's answer. -- A workflow step taken twice is refused with 409 and the current state. +- 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. +- 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`. +- 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). ### Audit (phase 1) @@ -122,13 +128,13 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved | I6 | Largely closed, phase 9 (groups plus reconcile); quarterly review IT's | | I7 | Closed, phase 1 | | I8 | App part closed, phase 1; kiosk lock policy IT's (Intune or GPO) | -| A1, A2, A3 | Closed, phase 2 | +| A1, A2, A3 | Closed, phase 2 (server policy on every call, workflow steps as compare-and-set, validation and limits on the server) | | 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) | | D6, D7 | Deferred (retention period and customer copy need a decision from Seth and Q.C.) | -| M1 | Closed, phase 2 | +| M1 | Closed, phase 2 (append-only audit and packet log written only by the server; every packet write carries both) | | M2 | Closed, phase 7 | | M3 | Closed, phase 9 | | M4, M5 | Deferred (logs to stdout now; SIEM shipping is IT's) | @@ -136,7 +142,7 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved | S2 | Closed, phase 1 | | S3 | Closed, phase 9 (CI scanning) | | S4 | Deferred to IT (pentest before go-live) | -| S5 | Closed, phase 2 | +| S5 | Closed, phase 2 (React escapes output; server messages are plain text; PDF export escaping comes with phase 7) | | S6, S7, S8 | Closed, phase 7 | | O1, O2 | Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's | | O3, O4 | Closed, phase 10 | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 68abb38..58e840b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,8 @@ "@fontsource/barlow-condensed": "5.3.0", "@fontsource/ibm-plex-mono": "5.3.0", "@fontsource/ibm-plex-sans": "5.3.0", + "pdfjs-dist": "6.3.289", + "qrcode": "1.5.4", "react": "18.3.1", "react-dom": "18.3.1", "react-router": "7.18.4" @@ -21,6 +23,7 @@ "@testing-library/jest-dom": "6.10.0", "@testing-library/react": "16.3.3", "@types/node": "22.20.5", + "@types/qrcode": "1.5.6", "@types/react": "18.3.31", "@types/react-dom": "18.3.7", "@vitejs/plugin-react": "4.7.0", @@ -1212,6 +1215,271 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.11.tgz", + "integrity": "sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.11", + "@napi-rs/canvas-darwin-arm64": "1.0.11", + "@napi-rs/canvas-darwin-x64": "1.0.11", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.11", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.11", + "@napi-rs/canvas-linux-arm64-musl": "1.0.11", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.11", + "@napi-rs/canvas-linux-x64-gnu": "1.0.11", + "@napi-rs/canvas-linux-x64-musl": "1.0.11", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.11", + "@napi-rs/canvas-win32-x64-msvc": "1.0.11" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.11.tgz", + "integrity": "sha512-dtJS4FQEfH9OwSYLtMrEnj7T8DzBbJc/97LI/OkiWOYpW/iW5t6R/gcxVrUXS8l45gCVpwMufgucRe/pe0ISqA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.11.tgz", + "integrity": "sha512-J/JylWoJTG8OQqZaiqly4IODyAxNofUlZo0wtSBJtHk6AdymbdIkPynAM1JvQaZpPaG5h+msk9QuK8y4i1ooqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.11.tgz", + "integrity": "sha512-nlecCk3xy06eAGC7krZtzNDm9UNTHKuHQYK8ZYegdjzgSL8anGt/eLJZm86OWaNgZkwQwvDqfSmG8Nqe6MPZNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.11.tgz", + "integrity": "sha512-DKImmG+sTPolE0kGPsYPBxTtOjb3INALp32O2lW2CfSr0bAzSEKKuu+GrQbtilClyoPOahUErS3OrznFDraoWA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.11.tgz", + "integrity": "sha512-55sykACGENj5j1rhj+81AO8JbR9HYgGjRZ7/H+pNr4VE1DvINKYNMTcutk8ZmWxk276jAQvUKJ3+Kdj9GXU3lQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.11.tgz", + "integrity": "sha512-6Jz6jys17yXydO+WdJgu7JhoB4K7/w9sWHByzzTmLjQghUx3TDMWvLv+UcCdnWhg4oprET67EMymnrETdeGNAg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.11.tgz", + "integrity": "sha512-lf2XeEebWiUYVOVEjLXxF7dfXrBBSEOBn9AqBh39VYXrPt5cDqI+1t58IFSjn8npqkg5jIOs8TQbjKABbFW1gQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.11.tgz", + "integrity": "sha512-iDapw7nFSE5tsmQxcH2+KotJhyfVPmHd6vUOQN/Em+8WNbkZJwJ/rDowlJMFnMl0SYSDSfgKCNUGi6Sn5++Smg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.11.tgz", + "integrity": "sha512-26q26AyITo3CGZX4HWFIMgBMA1lu3YN07gxzBm4tW6Hglq/OVyTP50kDrmWuKybCX6dh3m8Ly+c7YhI+sqCgCQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.11.tgz", + "integrity": "sha512-cUO96IdsfvIj2S+2PNcDSkTcLhjPj8pIOEWRV1Kj70MOP4wnE9zSk7N4C1EKHlFGRcgdR5AkbnBZj0dfHK9nbQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.11.tgz", + "integrity": "sha512-DFYp/ZQRdKunI/LPc0UkRXG1vlNjJKOJf8O8Ctb8+wm2Nt2ELg9Eamc31D2VoakkfGo8L+z0Iejz6WtFbQwyGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1815,6 +2083,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", @@ -2319,7 +2597,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2329,7 +2606,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2443,6 +2719,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001816", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", @@ -2491,11 +2776,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2508,7 +2803,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -2613,6 +2907,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -2637,6 +2940,12 @@ "node": ">=6" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2651,6 +2960,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -3049,6 +3364,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3196,6 +3520,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3565,6 +3898,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3595,7 +3937,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3618,6 +3959,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "6.3.289", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", + "integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3638,6 +3991,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.30", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", @@ -3715,6 +4077,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -3793,6 +4172,21 @@ "node": ">=8" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3895,6 +4289,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -3955,6 +4355,32 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -4445,6 +4871,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4472,6 +4904,20 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", @@ -4511,6 +4957,12 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4518,6 +4970,93 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0065d99..6f5792d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,8 @@ "@fontsource/barlow-condensed": "5.3.0", "@fontsource/ibm-plex-mono": "5.3.0", "@fontsource/ibm-plex-sans": "5.3.0", + "pdfjs-dist": "6.3.289", + "qrcode": "1.5.4", "react": "18.3.1", "react-dom": "18.3.1", "react-router": "7.18.4" @@ -25,6 +27,7 @@ "@testing-library/jest-dom": "6.10.0", "@testing-library/react": "16.3.3", "@types/node": "22.20.5", + "@types/qrcode": "1.5.6", "@types/react": "18.3.31", "@types/react-dom": "18.3.7", "@vitejs/plugin-react": "4.7.0", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1b62fc3..f581f1a 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -38,7 +38,7 @@ describe("routes", () => { mockFetch({ "/api/auth/me": { body: makeMe() } }); renderApp("/admin"); expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument(); - expect(screen.getByText("This screen arrives in phase 2.")).toBeInTheDocument(); + expect(screen.getByText("Projects and job packets")).toBeInTheDocument(); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f7e519b..263f2de 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,10 +4,13 @@ import { Layout } from "./components/Layout"; import { RequireAuth } from "./components/RequireAuth"; import { AboutPage } from "./pages/AboutPage"; import { HomePage } from "./pages/HomePage"; +import { PacketPage } from "./packet/PacketPage"; import { NotFoundPage } from "./pages/NotFoundPage"; import { SignInPage } from "./pages/SignInPage"; import { AccountPage } from "./screens/account/AccountPage"; -import { ScreenRoute } from "./screens/ScreenRoute"; +import { ProjectPage } from "./screens/clerical/ProjectPage"; +import { UnitPage } from "./screens/clerical/UnitPage"; +import { RequireScreen, ScreenRoute } from "./screens/ScreenRoute"; /** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */ export function AppRoutes() { @@ -20,6 +23,23 @@ export function AppRoutes() { }> } /> } /> + + + + } + /> + + + + } + /> + } /> } /> diff --git a/frontend/src/api/assets.ts b/frontend/src/api/assets.ts new file mode 100644 index 0000000..428e1ec --- /dev/null +++ b/frontend/src/api/assets.ts @@ -0,0 +1,19 @@ +// Asset upload and the blob URL. The upload is multipart through the shared request. +import { api, request } from "./client"; +import type { Asset } from "./types"; + +/** Same-origin URL of a stored file. */ +export function blobUrl(assetId: string, download = false): string { + return `/_blob/${encodeURIComponent(assetId)}${download ? "?download=1" : ""}`; +} + +export const assetsApi = { + upload: (file: File, opts: { packet_id?: number; purpose?: string } = {}) => { + const fd = new FormData(); + fd.append("file", file, file.name); + if (opts.packet_id !== undefined) fd.append("packet_id", String(opts.packet_id)); + if (opts.purpose) fd.append("purpose", opts.purpose); + return request("/api/assets", { method: "POST", body: fd }); + }, + get: (id: string, signal?: AbortSignal) => api.get(`/api/assets/${encodeURIComponent(id)}`, signal), +}; diff --git a/frontend/src/api/packets.ts b/frontend/src/api/packets.ts new file mode 100644 index 0000000..3197264 --- /dev/null +++ b/frontend/src/api/packets.ts @@ -0,0 +1,46 @@ +// Packet endpoints from the phase 2 contract. +import { api, request } from "./client"; +import { asList, ifMatch, qs } from "./query"; +import type { + AttachPagesBody, + BomLine, + FieldCas, + Guarded, + LogRow, + PacketCard, + PacketCreate, + PacketHeader, + PacketListQuery, + PacketRead, + TransitionBody, + WorkOrder, +} from "./types"; + +const base = (id: number) => `/api/packets/${encodeURIComponent(String(id))}`; + +export const packetsApi = { + list: async (q: PacketListQuery = {}, signal?: AbortSignal): Promise => + asList(await api.get(`/api/packets${qs({ limit: 500, ...q })}`, signal), "packets"), + get: (id: number, signal?: AbortSignal) => api.get(base(id), signal), + log: async (id: number, opts: { limit?: number; before?: number } = {}, signal?: AbortSignal): Promise => + asList(await api.get(`${base(id)}/log${qs({ limit: opts.limit ?? 300, before: opts.before })}`, signal), "log"), + create: (body: PacketCreate) => api.post("/api/packets", body), + patch: (id: number, body: FieldCas) => api.patch(base(id), body), + transition: (id: number, body: TransitionBody) => api.post(`${base(id)}/transitions`, body), + putWorkOrders: (id: number, version: number, work_orders: WorkOrder[], extra: Guarded = {}) => + request(`${base(id)}/work-orders`, { method: "PUT", body: { work_orders, ...extra }, headers: ifMatch(version) }), + putBom: (id: number, version: number, bom: BomLine[], extra: Guarded = {}) => + request(`${base(id)}/bom`, { method: "PUT", body: { bom, ...extra }, headers: ifMatch(version) }), + attachPages: (id: number, body: AttachPagesBody) => api.post(`${base(id)}/pages`, body), + removePages: (id: number, n: number, body: { reason?: string } & Guarded = {}) => + api.delWithBody(`${base(id)}/pages/${n}`, body), + addDoc: (id: number, body: { asset_id: string; kind: string; title: string } & Guarded) => + api.post(`${base(id)}/docs`, body), + removeDoc: (id: number, docId: number, extra: Guarded = {}) => + api.delWithBody(`${base(id)}/docs/${docId}`, extra), + move: (id: number, body: { unit_id: number; reason?: string } & Guarded) => api.post(`${base(id)}/move`, body), + remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody(base(id), body), + setVclosed: (id: number, closed: boolean, extra: Guarded = {}) => + api.post(`${base(id)}/vclosed`, { closed, ...extra }), + databook: (id: number) => api.post(`${base(id)}/databook`, {}), +}; diff --git a/frontend/src/api/projects.ts b/frontend/src/api/projects.ts new file mode 100644 index 0000000..1d0b0e2 --- /dev/null +++ b/frontend/src/api/projects.ts @@ -0,0 +1,44 @@ +// Project and unit endpoints from the phase 2 contract. +import { api } from "./client"; +import { asList, qs } from "./query"; +import type { FieldCas, Guarded, NumberLookup, Project, ProjectCreate, Unit, UnitsCreate } from "./types"; + +export type ProjectShow = "active" | "open" | "hold" | "closed" | "all"; + +export interface ProjectFields { + name: string | null; + customer: string | null; + po: string | null; + due: string | null; + status: Project["status"]; + notes: string | null; +} + +export interface UnitFields { + name: string | null; + due: string | null; + status: Unit["status"]; + notes: string | null; +} + +const pid = (id: number) => `/api/projects/${encodeURIComponent(String(id))}`; +const uid = (id: number) => `/api/units/${encodeURIComponent(String(id))}`; + +export const projectsApi = { + list: async (q: string, status: ProjectShow = "active", signal?: AbortSignal): Promise => + asList(await api.get(`/api/projects${qs({ q, status })}`, signal), "projects"), + get: (id: number, signal?: AbortSignal) => api.get(pid(id), signal), + create: (body: ProjectCreate & Guarded) => api.post("/api/projects", body), + patch: (id: number, body: FieldCas) => api.patch(pid(id), body), + renumber: (id: number, body: { number: string; reason: string } & Guarded) => api.post(`${pid(id)}/renumber`, body), + remove: (id: number, body: { reason: string } & Guarded) => api.delWithBody(pid(id), body), + addUnits: async (id: number, body: UnitsCreate & Guarded): Promise => + asList(await api.post(`${pid(id)}/units`, body), "units"), + units: async (projectId: number, q = "", signal?: AbortSignal): Promise => + asList(await api.get(`/api/units${qs({ project_id: projectId, q })}`, signal), "units"), + unit: (id: number, signal?: AbortSignal) => api.get(uid(id), signal), + patchUnit: (id: number, body: FieldCas) => api.patch(uid(id), body), + renumberUnit: (id: number, body: { lot: string; reason: string } & Guarded) => api.post(`${uid(id)}/renumber`, body), + removeUnit: (id: number, body: { reason: string } & Guarded) => api.delWithBody(uid(id), body), + lookup: (n: string, signal?: AbortSignal) => api.get(`/api/lookup/number${qs({ n })}`, signal), +}; diff --git a/frontend/src/api/query.ts b/frontend/src/api/query.ts new file mode 100644 index 0000000..fcc842d --- /dev/null +++ b/frontend/src/api/query.ts @@ -0,0 +1,26 @@ +// Query strings and list answers shared by the API modules. + +/** "?a=1&b=x" from the defined, non-empty values. Empty when nothing is set. */ +export function qs(params: Record): string { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === "") continue; + sp.set(k, String(v)); + } + const s = sp.toString(); + return s ? `?${s}` : ""; +} + +/** A list answer may come bare or wrapped as {key: [...]}. Both give the array. */ +export function asList(raw: unknown, key: string): T[] { + if (Array.isArray(raw)) return raw as T[]; + if (raw && typeof raw === "object" && Array.isArray((raw as Record)[key])) { + return (raw as Record)[key] as T[]; + } + return []; +} + +/** The If-Match header for a version check. */ +export function ifMatch(version: number): Record { + return { "If-Match": String(version) }; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..30a5abf --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,396 @@ +// API shapes from the phase 2 design contract. IDs stay text where the contract says so. +// Work order, lot, split, sub and part IDs are always strings. Timestamps are ISO strings. +import type { Factor } from "../auth/types"; + +export const STATUSES = ["draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete", "filed"] as const; +export type PacketStatus = (typeof STATUSES)[number]; + +export type ProjectStatus = "open" | "hold" | "closed"; +export type RouteKind = "databook" | "shipping"; + +/** Who references in packet answers. The frontend never gets emp_no or email here. */ +export interface WhoRef { + id: string; + display_name: string; +} + +export interface WelderRef extends WhoRef { + stamp: string | null; +} + +export interface StationRef { + id: number; + name: string; +} + +export interface DeptRef { + code: string; + name: string; + weld?: boolean; +} + +/** A signature in a request body. The server checks it against the session user. */ +export interface SignBody { + factor: Factor; + secret: string; +} + +/** Packet counts per stage on project and unit rows. */ +export interface StageCounts { + draft: number; + warehouse: number; + shop: number; + qc: number; + complete: number; + filed: number; + total: number; +} + +/* ---------- Projects and units ---------- */ + +export interface ProjectRef { + id: number; + number: string; + name: string | null; + customer?: string | null; + po?: string | null; + status?: ProjectStatus; + due?: string | null; +} + +/** One row of GET /api/units?project_id= and of a project's `units`. */ +export interface Unit { + id: number; + project_id?: number; + number: string; + lot: string; + name: string | null; + due: string | null; + due_effective: string | null; + status: ProjectStatus; + notes?: string | null; + packets?: StageCounts; + version: number; + project?: ProjectRef; +} + +/** One row of GET /api/projects. GET /api/projects/{id} adds units and aliases. */ +export interface Project { + id: number; + number: string; + name: string | null; + customer: string | null; + po: string | null; + due: string | null; + status: ProjectStatus; + notes: string | null; + source?: string; + needs_details?: boolean; + /** A count on list rows, the unit rows on GET /api/projects/{id}. */ + units: number | Unit[]; + packets?: StageCounts; + aliases?: Array; + version: number; +} + +export interface ProjectCreate { + number: string; + name: string; + customer: string; + po: string; + due: string | null; + notes: string; +} + +export interface UnitsCreate { + lot?: string; + lots?: string[]; + name: string; + due: string | null; + notes: string; +} + +export interface FieldCas { + set: Partial; + base: Partial; + it_reason?: string; +} + +export interface NumberLookup { + kind: "project" | "unit"; + id: number; + number: string; + alias: boolean; +} + +/* ---------- Packets ---------- */ + +export interface VclosedRef { + at: string; + by: WhoRef | null; +} + +/** One packet on a board. GET /api/packets -> {packets: [card...]}. */ +export interface PacketCard { + id: number; + version: number; + unit: { id: number; number: string }; + project: { id: number; number: string; name: string | null }; + dept: DeptRef; + op: string | null; + title: string | null; + customer: string | null; + drawing: string | null; + rev: string | null; + status: PacketStatus; + status_at: string | null; + station: StationRef | null; + welder: WelderRef | null; + routed_to: RouteKind | null; + vclosed: VclosedRef | null; + source_kind: "visual" | "hand"; + flag: string | null; + /** CO numbers, or a count. Both are handled. */ + open_cos: number[] | number; + open_shorts: number; + wh_stage: string | null; + due_effective: string | null; + updated_at: string | null; + holder: string; + can: string[]; + blocked: Record; + /** Not in the contract's card shape. Shown when the server sends it. */ + wo_count?: number; +} + +export interface PacketList { + packets: PacketCard[]; +} + +export interface PacketListQuery { + status?: string; + dept?: string; + unit_id?: number; + project_id?: number; + q?: string; + flag?: string; + limit?: number; +} + +export interface WorkOrder { + id?: number; + wo_base: string; + lot: string; + split: string; + sub: string; + seq?: number | null; + qty: number | string | null; + target_hrs: number | string | null; + op?: string | null; + description?: string | null; + visual_status?: string | null; + gone_at?: string | null; + row_version?: number; +} + +export interface PacketPage { + n: number; + type: "drawing" | "info" | "overlay" | string; + asset_id: string | null; + pn: number; + dwg: string | null; + rev: string | null; + void: { co_id: number; at: string; new_n: number | null } | null; + url: string; +} + +export interface BomShort { + id: number; + qty: number | string; + qty_open: number | string; + reason: string | null; + ref: string | null; + eta: string | null; + note: string | null; + opened_at: string | null; + receipts?: unknown[]; +} + +export interface BomLine { + id?: number; + item: number; + qty: number | string | null; + part_id: string | null; + description: string | null; + dwg_ref: string | null; + cl_item?: string | null; + length: string | null; + material: string | null; + heat?: string | null; + na?: boolean; + issued?: number | string | null; + loc?: string | null; + pulled?: boolean; + mtr_asset_id?: string | null; + src?: string | null; + short?: BomShort | null; + row_version?: number; +} + +export interface PacketDoc { + id: number; + asset_id: string; + kind: string; + title: string | null; + added_at: string | null; + added_by: WhoRef | null; + url: string; +} + +/** GET /api/packets/{id} and every packet mutation answer. Children the phase 2 screens do not use stay loose. */ +export interface PacketRead { + id: number; + version: number; + unit: { + id: number; + number: string; + lot: string; + name: string | null; + project: { id: number; number: string; name: string | null; customer: string | null; po: string | null; status: ProjectStatus }; + due_effective: string | null; + }; + dept: DeptRef; + op: string | null; + title: string | null; + customer: string | null; + drawing: string | null; + rev: string | null; + wps: string | null; + status: PacketStatus; + status_at: string | null; + station: StationRef | null; + welder: WelderRef | null; + for_sup: WhoRef | null; + heat_map: boolean | null; + wh_skipped: boolean; + wh_sent_at: string | null; + routed_to: RouteKind | null; + return_note: string | null; + vclosed: VclosedRef | null; + databook_at: string | null; + source_kind: "visual" | "hand"; + source: Record; + drawing_state: string; + flags: string[]; + visual: Record; + removed: { at: string; by: WhoRef | null; reason: string | null } | null; + work_orders: WorkOrder[]; + pages: PacketPage[]; + bom: BomLine[]; + welds: unknown[]; + heat_tags: unknown[]; + heat_table: unknown[]; + stage: Record | null; + stages: unknown[]; + wh: Record | null; + qc: Record | null; + qc_history: unknown[]; + change_orders: unknown[]; + open_cos: number[]; + open_shorts: number; + docs: PacketDoc[]; + seal: { sealed_at: string; key_id: string; broken_at: string | null } | null; + can: string[]; + blocked: Record; + holder: string; + checklist_items: Array<{ group: string; key: string; text: string }>; + qc_items: unknown[]; + flag: string | null; +} + +/** One row of GET /api/packets/{id}/log, newest first. */ +export interface LogRow { + id: number; + at: string; + by: WhoRef | null; + role: string | null; + action: string; + note: string | null; + it_reason: string | null; + signed: boolean; +} + +/** POST /api/packets: a hand draft. */ +export interface PacketCreate { + unit_id: number; + dept_code: string; + op: string; + title: string; + customer: string; + drawing: string; + rev: string; + wps: string; + heat_map?: boolean | null; + work_orders: Array & { op?: string; description?: string }>; + bom: Array>; +} + +/** The header fields PATCH /api/packets/{id} takes. */ +export interface PacketHeader { + title: string | null; + customer: string | null; + wps: string | null; + op: string | null; + heat_map: boolean | null; + for_sup_id: string | null; + drawing: string | null; + rev: string | null; + dept_code: string; +} + +/** POST /api/packets/{id}/transitions. A transition carries only its own fields. */ +export interface TransitionBody { + action: string; + expect: { status: PacketStatus; dept: string }; + reason?: string; + note?: string; + dept?: string; + station_id?: number; + welder_id?: string; + stamp?: string; + routed_to?: RouteKind; + book?: boolean; + spool?: boolean; + location?: string; + for_sup_id?: string; + sign?: SignBody; + it_reason?: string; +} + +export interface AttachPagesBody { + asset_id: string; + type: "drawing"; + dwg?: string; + rev?: string; + sign?: SignBody; + it_reason?: string; +} + +/* ---------- Assets ---------- */ + +/** POST /api/assets and GET /api/assets/{id}. */ +export interface Asset { + id: string; + mime: string; + size: number; + sha256: string; + pages: number | null; + original_name: string | null; + url: string; +} + +/** Extra fields the guard adds on a retry: the IT reason and the signature. */ +export interface Guarded { + it_reason?: string; + sign?: SignBody; +} diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index 59d1d2b..ef9aa9b 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { ApiError, errorCode, onApiError } from "../api/client"; +import { emitChanged } from "../lib/changes"; import { authApi, departmentsApi } from "./api"; import { AuthCtx, type AuthState, type AuthStatus } from "./context"; import { roleLabel as labelFor } from "./roles"; @@ -203,6 +204,7 @@ export function AuthProvider({ children, idleLockMs = IDLE_LOCK_MS, idleCheckMs const sessionId = status === "ready" || status === "locked" ? (me?.session.id ?? null) : null; const presence = useEvents(sessionId, { onRevoked: () => endSession("This session was signed out from another place."), + onChanged: emitChanged, }); const roleLabel = useCallback((role: string) => labelFor(role, departments), [departments]); diff --git a/frontend/src/auth/types.ts b/frontend/src/auth/types.ts index d06b33f..cf37f0b 100644 --- a/frontend/src/auth/types.ts +++ b/frontend/src/auth/types.ts @@ -173,8 +173,8 @@ export interface SigninAttempt { reason: string | null; } -/** Stations may come as names or as rows. */ -export type StationEntry = string | { name: string; active?: boolean }; +/** Stations come as rows with their ids (ASSIGN needs the id). Older answers were plain names. */ +export type StationEntry = string | { id?: number; name: string; active?: boolean }; /** One row of GET /api/departments. */ export interface Department { @@ -182,6 +182,7 @@ export interface Department { slug: string; name: string; kind: "support" | "shop"; + weld?: boolean; stations: StationEntry[]; } diff --git a/frontend/src/components/Board.tsx b/frontend/src/components/Board.tsx new file mode 100644 index 0000000..7ddebe7 --- /dev/null +++ b/frontend/src/components/Board.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import type { PacketCard } from "../api/types"; +import { Card } from "./Card"; + +export interface ColumnDef { + key: string; + title: string; + cards: PacketCard[]; + /** Shown when the column is empty. */ + empty: ReactNode; +} + +/** The old column(): a titled column of cards with a count, or the empty note. */ +export function Column({ title, cards, empty }: Omit) { + const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; + return ( +
+

+ {title} + {cards.length} +

+ {cards.length ? cards.map((p) => ) :
{empty}
} +
+ ); +} + +/** A row of columns. */ +export function Board({ columns }: { columns: ColumnDef[] }) { + return ( +
+ {columns.map((c) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx new file mode 100644 index 0000000..1305e24 --- /dev/null +++ b/frontend/src/components/Card.tsx @@ -0,0 +1,46 @@ +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"; + +/** 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); + return ( + +
+ {jobName(p)} + {statusLabel(p.status)} +
+
{p.title || "Untitled"}
+
{p.dept.name}
+
+ + Dwg {p.drawing || "-"} + {p.rev ? <> R{p.rev} : null} + + {p.wo_count !== undefined ? ( + + {p.wo_count} WO + + ) : null} + {p.station ? ( + + {p.station.name} + {p.welder ? ` · ${p.welder.display_name}` : ""} + + ) : null} + {p.status === "complete" && p.routed_to ? ( + + To {p.routed_to === "databook" ? "Clerical (data book)" : "Shipping"} + + ) : null} + {shorts ? {shorts} short : null} + {cos ? CO {cos} : null} +
+ {p.flag ?
⚑ {p.flag}
: null} + {extra} + + ); +} diff --git a/frontend/src/components/FindBar.tsx b/frontend/src/components/FindBar.tsx new file mode 100644 index 0000000..bff6c0b --- /dev/null +++ b/frontend/src/components/FindBar.tsx @@ -0,0 +1,63 @@ +import { PickUnit, type UnitPick } from "./PickUnit"; + +export type BoardView = "board" | "list"; + +interface FindBarProps { + q: string; + onQ: (q: string) => void; + pick: UnitPick; + onPick: (v: UnitPick) => void; + view: BoardView; + onView: (v: BoardView) => void; + /** "Showing n packets". */ + count: number | null; +} + +/** 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 }: FindBarProps) { + return ( +
+
+ +
+ +
+
+
+ + +
+
+
+ {count !== null ? ( +
+ + + Showing {count} packet{count === 1 ? "" : "s"} + +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/Kpis.tsx b/frontend/src/components/Kpis.tsx new file mode 100644 index 0000000..5fc0abe --- /dev/null +++ b/frontend/src/components/Kpis.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +export interface Kpi { + n: ReactNode; + label: string; + /** Turns pink when above zero. */ + alert?: boolean; +} + +/** The old kpis(): number tiles. Zero is grey, an alert above zero is pink. */ +export function Kpis({ items, label = "Numbers" }: { items: Kpi[]; label?: string }) { + return ( +
+ {items.map((k) => { + const zero = String(k.n) === "0"; + const cls = ["kpi", k.alert && !zero ? "hot" : "", zero ? "zero" : ""].filter(Boolean).join(" "); + return ( +
+
{k.n}
+
{k.label}
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/PickUnit.tsx b/frontend/src/components/PickUnit.tsx new file mode 100644 index 0000000..d813215 --- /dev/null +++ b/frontend/src/components/PickUnit.tsx @@ -0,0 +1,148 @@ +import { useEffect, useId, useState } from "react"; +import { projectsApi } from "../api/projects"; +import type { Project, ProjectRef, Unit } from "../api/types"; +import { errorText } from "../lib/format"; +import { projectPillClass, PROJECT_STATUS_LABEL } from "../lib/packets"; + +export interface UnitPick { + project: ProjectRef | null; + unit: Unit | null; +} + +interface PickUnitProps { + value: UnitPick; + onChange: (v: UnitPick) => void; + /** A packet must go in a unit: no "All units" choice, and closed units are left out. */ + requireUnit?: boolean; + /** Label of the first step. */ + label?: string; +} + +/** Two steps: find the project, then pick one of its units. */ +export function PickUnit({ value, onChange, requireUnit = false, label = "Project" }: PickUnitProps) { + const id = useId(); + const [q, setQ] = useState(""); + const [query, setQuery] = useState(""); + const [hits, setHits] = useState(null); + const [units, setUnits] = useState(null); + const [error, setError] = useState(null); + const projectId = value.project?.id ?? null; + + useEffect(() => { + const t = window.setTimeout(() => setQuery(q.trim()), 250); + return () => window.clearTimeout(t); + }, [q]); + + // Step one: projects matching the search, while no project is chosen. + useEffect(() => { + if (projectId !== null) return; + const ctl = new AbortController(); + projectsApi + .list(query, "active", ctl.signal) + .then((list) => { + if (!ctl.signal.aborted) setHits(list); + }) + .catch((err: unknown) => { + if (!ctl.signal.aborted) setError(errorText(err)); + }); + return () => ctl.abort(); + }, [query, projectId]); + + // Step two: the chosen project's units. + useEffect(() => { + if (projectId === null) { + setUnits(null); + return; + } + const ctl = new AbortController(); + projectsApi + .units(projectId, "", ctl.signal) + .then((list) => { + if (!ctl.signal.aborted) setUnits(list); + }) + .catch((err: unknown) => { + if (!ctl.signal.aborted) setError(errorText(err)); + }); + return () => ctl.abort(); + }, [projectId]); + + const choose = (p: Project) => { + setError(null); + onChange({ project: { id: p.id, number: p.number, name: p.name, customer: p.customer, po: p.po, status: p.status, due: p.due }, unit: null }); + }; + + if (!value.project) { + const shown = (hits ?? []).slice(0, 8); + return ( +
+ + {error ?
{error}
: null} + {hits === null ? null : shown.length ? ( +
+ {shown.map((p) => ( + + ))} +
+ ) : ( +
{query ? `Nothing matches "${query}".` : "No open projects yet."}
+ )} +
+ ); + } + + const list = (units ?? []).filter((u) => !requireUnit || u.status !== "closed" || u.id === value.unit?.id); + return ( +
+
+ {label} + {value.project.number} {value.project.name ?? ""} + +
+ + {units === null ? ( +
Loading units
+ ) : list.length === 0 ? ( +
This project has no open units. Add one from the project page first.
+ ) : ( + + )} + {error ?
{error}
: null} +
+ ); +} diff --git a/frontend/src/components/ReasonSheet.tsx b/frontend/src/components/ReasonSheet.tsx new file mode 100644 index 0000000..3ad81e5 --- /dev/null +++ b/frontend/src/components/ReasonSheet.tsx @@ -0,0 +1,75 @@ +import { useState, type FormEvent } from "react"; +import { Sheet } from "./Sheet"; + +export interface ReasonOptions { + title: string; + sub?: string | undefined; + /** The field label. */ + label?: string; + /** A line under the title. */ + note?: string | undefined; + placeholder?: string | undefined; + /** Shortest reason accepted. 0 makes the field optional. */ + min?: number; + max?: number; + submitLabel?: string; + danger?: boolean; +} + +interface ReasonSheetProps extends ReasonOptions { + onSubmit: (reason: string) => void; + onCancel: () => void; +} + +/** Asks for a reason, or an optional note, before a step. */ +export function ReasonSheet({ + title, + sub, + label = "Reason", + note, + placeholder, + min = 1, + max = 500, + submitLabel = "Continue", + danger = false, + onSubmit, + onCancel, +}: ReasonSheetProps) { + const [text, setText] = useState(""); + const [error, setError] = useState(null); + + const submit = (e: FormEvent) => { + e.preventDefault(); + const t = text.trim(); + if (t.length < min) { + setError(min <= 1 ? "Give a reason." : `Give a reason of at least ${min} characters.`); + return; + } + onSubmit(t); + }; + + return ( + + {note ?
{note}
: null} +
+ + {error ? ( +
+ {error} +
+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Sheet.tsx b/frontend/src/components/Sheet.tsx new file mode 100644 index 0000000..2da382e --- /dev/null +++ b/frontend/src/components/Sheet.tsx @@ -0,0 +1,60 @@ +import { useEffect, useId, useRef, type ReactNode } from "react"; + +interface SheetProps { + title: string; + /** Small text next to the title, like the job name. */ + sub?: string | undefined; + onClose: () => void; + children: ReactNode; + /** The old sheets were 560 px for short forms and 720 px or more for long ones. */ + wide?: boolean; +} + +/** Open sheets, innermost last. Only the innermost one answers Escape. */ +const open: string[] = []; + +/** A modal sheet over a scrim. Escape and a click on the scrim close it. The first field gets the focus. */ +export function Sheet({ title, sub, onClose, children, wide = false }: SheetProps) { + const id = useId(); + const ref = useRef(null); + const closeRef = useRef(onClose); + useEffect(() => { + closeRef.current = onClose; + }); + + useEffect(() => { + const el = ref.current; + const first = el?.querySelector("input:not([type=hidden]):not([type=file]):not([disabled]), select, textarea"); + (first ?? el)?.focus(); + open.push(id); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && open[open.length - 1] === id) { + e.stopPropagation(); + closeRef.current(); + } + }; + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("keydown", onKey); + const i = open.lastIndexOf(id); + if (i >= 0) open.splice(i, 1); + }; + }, [id]); + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+

{title}

+ {sub ? {sub} : null} +
+ {children} +
+
+ ); +} diff --git a/frontend/src/components/SignatureSheet.tsx b/frontend/src/components/SignatureSheet.tsx new file mode 100644 index 0000000..3ed68d5 --- /dev/null +++ b/frontend/src/components/SignatureSheet.tsx @@ -0,0 +1,154 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { ApiError, errorCode } from "../api/client"; +import type { SignBody } from "../api/types"; +import type { Factor } from "../auth/types"; +import { useAuth } from "../auth/useAuth"; +import { errorText } from "../lib/format"; +import { applyKey, type KeypadKey } from "../lib/keypad"; +import { Keypad } from "./Keypad"; +import { Sheet } from "./Sheet"; + +const PIN_MAX = 12; + +interface SignatureSheetProps { + /** What the signature means, from the rule. */ + meaning: string; + sub?: string | undefined; + /** Sends the step with the signature. A wrong secret keeps the sheet open. */ + onSign: (sign: SignBody) => Promise; + /** Any other failure. The caller closes the sheet. */ + onFail: (err: unknown) => void; + onCancel: () => void; +} + +function attemptsLeft(err: unknown): number | null { + if (err instanceof ApiError && err.body && typeof err.body === "object" && "attempts_left" in err.body) { + const n = Number((err.body as { attempts_left: unknown }).attempts_left); + return Number.isFinite(n) ? n : null; + } + return null; +} + +/** Signs a step with the user's PIN or password. */ +export function SignatureSheet({ meaning, sub, onSign, onFail, onCancel }: SignatureSheetProps) { + const auth = useAuth(); + const me = auth.me; + const hasPin = me ? me.pin.set && !me.pin.disabled : false; + const [factor, setFactor] = useState(hasPin ? "pin" : "password"); + const [secret, setSecret] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [left, setLeft] = useState(null); + const [missed, setMissed] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, [factor]); + + const switchTo = (f: Factor) => { + setFactor(f); + setSecret(""); + setError(null); + }; + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (!secret || busy) return; + setBusy(true); + setError(null); + try { + await onSign({ factor, secret }); + } catch (err) { + const code = errorCode(err); + if (code === "session_ended") { + // The auth layer signs out on this 401 too. Make sure, with the server's words. + auth.endSession(errorText(err)); + return; + } + if (code === "wrong_secret" || code === "bad_secret") { + setError(errorText(err)); + setLeft(attemptsLeft(err)); + setMissed(true); + setSecret(""); + setBusy(false); + inputRef.current?.focus(); + return; + } + onFail(err); + } + }; + + const onKey = (k: KeypadKey) => setSecret((v) => applyKey(v, k, PIN_MAX)); + + return ( + +
+
This signature means
+
{meaning}
+
+
+ You are signing as {me?.user.display_name ?? "yourself"}. +
+ {error ? ( +
+ {error} + {left !== null ?
{left === 1 ? "1 attempt left." : `${left} attempts left.`}
: null} + {missed ?
Three wrong attempts end the session.
: null} +
+ ) : null} +
+ {factor === "pin" ? ( + <> + + + + ) : ( + + )} +
+ {factor === "pin" ? ( + + ) : hasPin ? ( + + ) : null} + + + +
+ +
+ ); +} diff --git a/frontend/src/components/useGuard.tsx b/frontend/src/components/useGuard.tsx new file mode 100644 index 0000000..2bbf874 --- /dev/null +++ b/frontend/src/components/useGuard.tsx @@ -0,0 +1,156 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { ApiError, errorCode } from "../api/client"; +import type { Guarded, SignBody } from "../api/types"; +import { ReasonSheet, type ReasonOptions } from "./ReasonSheet"; +import { SignatureSheet } from "./SignatureSheet"; + +type Ask = + | { kind: "reason"; opts: ReasonOptions; resolve: (r: string | null) => void } + | { + kind: "sign"; + meaning: string; + sub: string | undefined; + attempt: (sign: SignBody) => Promise; + resolve: (r: { ok: true; value: unknown } | { ok: false }) => void; + reject: (err: unknown) => void; + }; + +export interface RunOptions { + /** The signature meaning when the step is known to need one. Otherwise the sheet opens on 400 signature_required. */ + sign?: string | undefined; + /** Shown next to the sheet titles, like the job name. */ + sub?: string | undefined; +} + +export interface Guard { + /** + * Runs a call. On 400 it_reason_required it asks for the IT reason and retries with `it_reason`. + * On 400 signature_required it opens the signature sheet and retries with `sign`. + * Resolves undefined when the user cancels. Other errors are thrown. + */ + run: (fn: (g: Guarded) => Promise, opts?: RunOptions) => Promise; + /** Asks for a reason. Null when cancelled. */ + askReason: (opts: ReasonOptions) => Promise; + /** The open sheet, if any. Render it once in the screen. */ + sheet: ReactNode; +} + +export const IT_REASON: ReasonOptions = { + title: "IT reason", + label: "Why IT is making this change", + note: "You are changing this as IT. The reason is recorded with the change.", + min: 8, + submitLabel: "Continue", +}; + +function meaningOf(err: unknown): string { + if (err instanceof ApiError && err.body && typeof err.body === "object" && "meaning" in err.body) { + const m = (err.body as { meaning: unknown }).meaning; + if (typeof m === "string" && m) return m; + } + return "Sign this step"; +} + +/** The IT reason and signature retries, as sheets. */ +export function useGuard(): Guard { + const [ask, setAsk] = useState(null); + + const askReason = useCallback( + (opts: ReasonOptions) => + new Promise((resolve) => { + setAsk({ kind: "reason", opts, resolve }); + }), + [], + ); + + const askSign = useCallback( + (meaning: string, sub: string | undefined, attempt: (sign: SignBody) => Promise) => + new Promise<{ ok: true; value: T } | { ok: false }>((resolve, reject) => { + setAsk({ + kind: "sign", + meaning, + sub, + attempt, + resolve: resolve as (r: { ok: true; value: unknown } | { ok: false }) => void, + reject, + }); + }), + [], + ); + + const run = useCallback( + async (fn: (g: Guarded) => Promise, opts: RunOptions = {}): Promise => { + let itReason: string | undefined; + let meaning = opts.sign; + for (let round = 0; round < 4; round++) { + const call = (sign?: SignBody) => { + const g: Guarded = {}; + if (itReason) g.it_reason = itReason; + if (sign) g.sign = sign; + return fn(g); + }; + try { + if (meaning) { + const r = await askSign(meaning, opts.sub, call); + return r.ok ? r.value : undefined; + } + return await call(); + } catch (err) { + const code = errorCode(err); + if (code === "it_reason_required" && !itReason) { + const r = await askReason({ ...IT_REASON, sub: opts.sub }); + if (r === null) return undefined; + itReason = r; + continue; + } + if (code === "signature_required" && !meaning) { + meaning = meaningOf(err); + continue; + } + throw err; + } + } + return undefined; + }, + [askReason, askSign], + ); + + let sheet: ReactNode = null; + if (ask?.kind === "reason") { + sheet = ( + { + setAsk(null); + ask.resolve(r); + }} + onCancel={() => { + setAsk(null); + ask.resolve(null); + }} + /> + ); + } else if (ask?.kind === "sign") { + sheet = ( + { + const value = await ask.attempt(sign); + setAsk(null); + ask.resolve({ ok: true, value }); + }} + onFail={(err) => { + setAsk(null); + ask.reject(err); + }} + onCancel={() => { + setAsk(null); + ask.resolve({ ok: false }); + }} + /> + ); + } + + return useMemo(() => ({ run, askReason, sheet }), [run, askReason, sheet]); +} diff --git a/frontend/src/lib/access.ts b/frontend/src/lib/access.ts new file mode 100644 index 0000000..2d2bbed --- /dev/null +++ b/frontend/src/lib/access.ts @@ -0,0 +1,11 @@ +// What the screens offer. The server decides on every request; this only hides what cannot work. + +/** Clerical (or a Clerical supervisor) sets up projects and units. Admin can, with an IT reason. */ +export function canEditProjects(roles: readonly string[]): boolean { + return roles.some((r) => r === "clerical" || r === "clerical_supervisor" || r === "admin"); +} + +/** Renumbering a project or unit needs a Clerical supervisor, or admin. */ +export function canRenumber(roles: readonly string[]): boolean { + return roles.some((r) => r === "clerical_supervisor" || r === "admin"); +} diff --git a/frontend/src/lib/changes.ts b/frontend/src/lib/changes.ts new file mode 100644 index 0000000..68e2ba3 --- /dev/null +++ b/frontend/src/lib/changes.ts @@ -0,0 +1,53 @@ +// The `changed` events from /api/events, fanned out to the open screens. +// AuthProvider owns the one EventSource and feeds every payload in here. +import { useEffect, useRef } from "react"; + +export interface ChangedEvent { + event?: string; + packet_id?: number; + version?: number; + status?: string; + dept?: string; + [key: string]: unknown; +} + +type Listener = (ev: ChangedEvent) => void; +const listeners = new Set(); + +export function emitChanged(payload: unknown): void { + const ev: ChangedEvent = payload && typeof payload === "object" ? (payload as ChangedEvent) : {}; + for (const fn of listeners) fn(ev); +} + +export function onChanged(fn: Listener): () => void { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +} + +/** + * Calls `fn` after `changed` events. With `delayMs`, a burst of events gives one call + * `delayMs` after the last one. `filter` drops events the screen does not care about. + */ +export function useChanged(fn: () => void, opts: { delayMs?: number; filter?: (ev: ChangedEvent) => boolean } = {}): void { + const fnRef = useRef(fn); + const filterRef = useRef(opts.filter); + useEffect(() => { + fnRef.current = fn; + filterRef.current = opts.filter; + }); + const delay = opts.delayMs ?? 500; + useEffect(() => { + let t: number | undefined; + const off = onChanged((ev) => { + if (filterRef.current && !filterRef.current(ev)) return; + window.clearTimeout(t); + t = window.setTimeout(() => fnRef.current(), delay); + }); + return () => { + off(); + window.clearTimeout(t); + }; + }, [delay]); +} diff --git a/frontend/src/lib/packets.ts b/frontend/src/lib/packets.ts new file mode 100644 index 0000000..71a906f --- /dev/null +++ b/frontend/src/lib/packets.ts @@ -0,0 +1,114 @@ +// Status labels, pills, job names and due dates. Copy from the old build where it had one. +import type { PacketCard, PacketRead, PacketStatus, ProjectStatus, StageCounts } from "../api/types"; + +export const STATUS_LABEL: Record = { + draft: "Ready to release", + warehouse: "With warehouse", + ready: "Ready to assign", + assigned: "Sent to station", + in_progress: "Working", + review: "Supervisor review", + qc: "Q.C. inspection", + complete: "Routed", + filed: "Filed in data book", +}; + +export function statusLabel(s: string): string { + return (STATUS_LABEL as Record)[s] ?? s; +} + +/** The pill class for a status: "pill st-". */ +export function pillClass(s: string): string { + return `pill st-${s}`; +} + +export const SHOP: readonly PacketStatus[] = ["ready", "assigned", "in_progress", "review"]; +export const IN_SHOP_OR_QC: readonly PacketStatus[] = [...SHOP, "qc"]; + +/** "L999001 · WELD-PART". */ +export function jobName(p: Pick | Pick): string { + return p.op ? `${p.unit.number} · ${p.op}` : p.unit.number; +} + +export const PROJECT_STATUS_LABEL: Record = { open: "Open", hold: "On hold", closed: "Closed" }; + +/** The old projPill colours: open green, on hold pink, closed grey. */ +export function projectPillClass(s: ProjectStatus): string { + return `pill ${s === "open" ? "st-in_progress" : s === "hold" ? "st-review" : "st-filed"}`; +} + +/** A flag text that names a hard problem shows in pink, the rest in amber. */ +export function flagClass(flag: string): string { + return /couldn't|not in|mismatch|No approved|Not released/i.test(flag) ? "due over" : "due soon"; +} + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** The old dueInfo(): "Due Oct 20 '26 · 4 d left", with a class for colour. */ +export function dueInfo(due: string | null | undefined, closed = false, today: Date = new Date()): { txt: string; cls: string } { + if (!due) return { txt: "No due date", cls: "due muted" }; + const d = new Date(`${due.slice(0, 10)}T12:00:00`); + if (Number.isNaN(d.getTime())) return { txt: `Due ${due}`, cls: "due" }; + const noon = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 12); + const days = Math.round((d.getTime() - noon.getTime()) / 86_400_000); + const lab = `${MONTHS[d.getMonth()] ?? ""} ${d.getDate()} '${String(d.getFullYear()).slice(2)}`; + if (closed) return { txt: `Due ${lab}`, cls: "due muted" }; + if (days < 0) return { txt: `Due ${lab} · ${-days} d overdue`, cls: "due over" }; + if (days <= 7) return { txt: `Due ${lab} · ${days === 0 ? "today" : `${days} d left`}`, cls: "due soon" }; + return { txt: `Due ${lab} · ${days} d left`, cls: "due" }; +} + +/** The open change orders on a card, as a count. */ +export function openCoCount(p: Pick): number { + return Array.isArray(p.open_cos) ? p.open_cos.length : Number(p.open_cos) || 0; +} + +/** Sum of the work orders' target hours. */ +export function targetHours(p: Pick): number { + return p.work_orders.reduce((a, w) => a + (Number(w.target_hrs) || 0), 0); +} + +/** "001" from "1", "7A" stays "7A". The server does the same. */ +export function normLot(v: string): string { + const t = v.trim().toUpperCase(); + return /^\d{1,3}$/.test(t) ? t.padStart(3, "0") : t; +} + +export const RX_LOT = /^[A-Z0-9]{1,3}$/; +export const RX_PROJECT = /^[A-Z0-9][A-Z0-9-]{0,19}$/; + +/** Lots counting up from a numeric start: ("008", 3) -> 008, 009, 010. Null when the start is not digits. */ +export function lotSequence(start: string, count: number): string[] | null { + const s = start.trim(); + if (!/^\d{1,3}$/.test(s) || !Number.isInteger(count) || count < 1) return null; + const b = Number(s); + if (b + count - 1 > 999) return null; + return Array.from({ length: count }, (_, i) => String(b + i).padStart(3, "0")); +} + +/** The next free numeric lot after the ones in use. */ +export function nextLot(lots: readonly string[]): string { + const nums = lots.filter((l) => /^\d+$/.test(l)).map(Number); + const n = nums.length ? Math.max(...nums) + 1 : 1; + return String(Math.min(n, 999)).padStart(3, "0"); +} + +/** Packet counts with every stage present. */ +export function stageCounts(c: Partial | undefined | null): StageCounts { + return { draft: 0, warehouse: 0, shop: 0, qc: 0, complete: 0, filed: 0, total: 0, ...(c ?? {}) }; +} + +/** Counts of a list of cards, the same way. */ +export function countCards(list: readonly Pick[]): StageCounts { + const c = stageCounts(null); + for (const p of list) { + c.total++; + if (p.status === "draft") c.draft++; + else if (p.status === "warehouse") c.warehouse++; + else if ((SHOP as readonly string[]).includes(p.status)) c.shop++; + else if (p.status === "qc") c.qc++; + else if (p.status === "complete") c.complete++; + else if (p.status === "filed") c.filed++; + } + return c; +} diff --git a/frontend/src/lib/pdf.ts b/frontend/src/lib/pdf.ts new file mode 100644 index 0000000..9d801e0 --- /dev/null +++ b/frontend/src/lib/pdf.ts @@ -0,0 +1,54 @@ +// pdf.js, loaded on first use. The worker is a same-origin file from the build (CSP worker-src 'self'). +import type { PDFDocumentProxy } from "pdfjs-dist"; +import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; + +type PdfJs = typeof import("pdfjs-dist"); + +let lib: Promise | null = null; + +export function loadPdfJs(): Promise { + if (!lib) { + lib = import("pdfjs-dist").then((m) => { + m.GlobalWorkerOptions.workerSrc = workerUrl; + return m; + }); + lib.catch(() => { + lib = null; + }); + } + return lib; +} + +interface Entry { + promise: Promise; + destroy: () => void; +} + +const docs = new Map(); +const MAX_DOCS = 6; + +/** + * Opens a PDF from a same-origin URL, once per URL. The session cookie rides along. + * WebAssembly decoders stay off: the CSP does not allow wasm, and drawings rarely need them. + */ +export function openPdf(url: string): Promise { + const hit = docs.get(url); + if (hit) return hit.promise; + let destroy = () => undefined as void; + const promise = loadPdfJs().then((m) => { + const task = m.getDocument({ url, withCredentials: true, useWasm: false }); + destroy = () => void task.destroy().catch(() => undefined); + return task.promise; + }); + const entry: Entry = { promise, destroy: () => destroy() }; + docs.set(url, entry); + promise.catch(() => docs.delete(url)); + // Keep a few documents open; drop the oldest. + while (docs.size > MAX_DOCS) { + const first = docs.keys().next().value; + if (first === undefined) break; + docs.get(first)?.destroy(); + docs.delete(first); + } + return promise; +} diff --git a/frontend/src/lib/useDepartments.ts b/frontend/src/lib/useDepartments.ts new file mode 100644 index 0000000..2479a6b --- /dev/null +++ b/frontend/src/lib/useDepartments.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; +import { departmentsApi } from "../auth/api"; +import type { Department } from "../auth/types"; +import { useAuth } from "../auth/useAuth"; + +/** The shop departments from /api/departments, in code order. */ +export function useShopDepartments(): Department[] { + const { departments } = useAuth(); + const [own, setOwn] = useState(null); + useEffect(() => { + if (departments.length) return; + const ctl = new AbortController(); + departmentsApi + .list(ctl.signal) + .then((d) => { + if (!ctl.signal.aborted) setOwn(Array.isArray(d) ? d : []); + }) + .catch(() => undefined); + return () => ctl.abort(); + }, [departments.length]); + const list = departments.length ? departments : (own ?? []); + return list.filter((d) => d.kind === "shop").sort((a, b) => a.code.localeCompare(b.code)); +} + diff --git a/frontend/src/packet/DrawingViewer.tsx b/frontend/src/packet/DrawingViewer.tsx new file mode 100644 index 0000000..7ab28d4 --- /dev/null +++ b/frontend/src/packet/DrawingViewer.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from "react"; +import type { PacketPage } from "../api/types"; +import { openPdf } from "../lib/pdf"; + +const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; + +/** The drawing pages, one at a time, fitted to the width. Zoom buttons and a page list. No marking yet. */ +export function DrawingViewer({ pages }: { pages: PacketPage[] }) { + const [sel, setSel] = useState(0); + const [zi, setZi] = useState(2); + const [width, setWidth] = useState(0); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const page = pages[Math.min(sel, pages.length - 1)]; + const zoom = ZOOMS[zi] ?? 1; + const url = page?.url ?? ""; + const pn = page?.pn ?? 1; + + // Fit to width: follow the frame's width. + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const measure = () => setWidth(el.clientWidth); + measure(); + if (typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (!url || !width) return; + let cancelled = false; + let task: { cancel: () => void; promise: Promise } | null = null; + setBusy(true); + setError(null); + openPdf(url) + .then((doc) => doc.getPage(Math.max(1, Math.min(pn || 1, doc.numPages)))) + .then(async (pg) => { + if (cancelled) return; + const canvas = canvasRef.current; + if (!canvas) return; + const base = pg.getViewport({ scale: 1 }); + const scale = (Math.max(200, width - 2) / base.width) * zoom; + const ratio = window.devicePixelRatio || 1; + const vp = pg.getViewport({ scale: scale * ratio }); + canvas.width = Math.floor(vp.width); + canvas.height = Math.floor(vp.height); + canvas.style.width = `${Math.floor(vp.width / ratio)}px`; + canvas.style.height = `${Math.floor(vp.height / ratio)}px`; + task = pg.render({ canvas, viewport: vp }); + await task.promise; + if (!cancelled) setBusy(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (err && typeof err === "object" && "name" in err && (err as { name: string }).name === "RenderingCancelledException") return; + setBusy(false); + setError("The drawing could not be shown. Open it in a new tab instead."); + }); + return () => { + cancelled = true; + task?.cancel(); + }; + }, [url, pn, width, zoom]); + + if (!page) return
No drawing attached yet.
; + + return ( +
+
+
+ + + +
+ {Math.round(zoom * 100)}% + + + Open in a new tab + +
+
+
    + {pages.map((p, i) => ( +
  1. + +
  2. + ))} +
+
+ {error ?
{error}
: null} + {busy && !error ?
Drawing the page
: null} + +
+
+
+ ); +} diff --git a/frontend/src/packet/PacketPage.test.tsx b/frontend/src/packet/PacketPage.test.tsx new file mode 100644 index 0000000..6d5b6ad --- /dev/null +++ b/frontend/src/packet/PacketPage.test.tsx @@ -0,0 +1,190 @@ +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DEPARTMENTS, makeMe, renderApp } from "../test/auth"; +import { callsTo, mockFetch } from "../test/fetchMock"; +import { makePacket } from "../test/packets"; + +function base(packet = makePacket()) { + return { + "/api/auth/me": { body: makeMe() }, + "/api/departments": { body: DEPARTMENTS }, + "/api/packets/1": { body: packet }, + "/api/packets/1/log": { body: [] }, + }; +} + +describe("PacketPage", () => { + it("shows Send to warehouse disabled with the blocked reason", async () => { + mockFetch(base()); + renderApp("/packets/1"); + const send = await screen.findByRole("button", { name: "Send to warehouse" }); + expect(send).toBeDisabled(); + expect(screen.getByText("Say whether this packet needs a heat map.")).toHaveClass("why"); + expect(screen.getByRole("heading", { name: "L999001 · WELD-PART" })).toBeInTheDocument(); + expect(screen.getByTestId("status-pill")).toHaveTextContent("Ready to release"); + expect(screen.queryByText("View only.")).not.toBeInTheDocument(); + }); + + it("enables Send to warehouse when can includes it, and posts the transition with expect", async () => { + const ready = makePacket({ heat_map: true, can: ["edit_header", "send_to_warehouse"], blocked: {} }); + const fetch = mockFetch({ + ...base(ready), + "POST /api/packets/1/transitions": { body: makePacket({ status: "warehouse", version: 4, can: [], blocked: {}, holder: "Warehouse" }) }, + }); + renderApp("/packets/1"); + const send = await screen.findByRole("button", { name: "Send to warehouse" }); + expect(send).toBeEnabled(); + expect(screen.queryByText("Say whether this packet needs a heat map.")).not.toBeInTheDocument(); + fireEvent.click(send); + await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse")); + expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([{ action: "send_to_warehouse", expect: { status: "draft", dept: "03" } }]); + expect(screen.getByText("View only.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Send to warehouse" })).not.toBeInTheDocument(); + }); + + it("heat map chips PATCH with the base value", async () => { + const fetch = mockFetch({ + ...base(), + "PATCH /api/packets/1": { body: makePacket({ heat_map: true, version: 4 }) }, + }); + renderApp("/packets/1"); + const group = await screen.findByRole("group", { name: "Heat map required?" }); + fireEvent.click(within(group).getByRole("button", { name: "Yes" })); + await waitFor(() => expect(within(group).getByRole("button", { name: "Yes" })).toHaveAttribute("aria-pressed", "true")); + expect(callsTo(fetch, "PATCH", "/api/packets/1")).toEqual([{ set: { heat_map: true }, base: { heat_map: null } }]); + }); + + it("attaching a drawing uploads it, then signs and sends sign", async () => { + const fetch = mockFetch({ + ...base(), + "POST /api/assets": { + status: 201, + body: { id: "a1b2", mime: "application/pdf", size: 900, sha256: "00", pages: 1, original_name: "d.pdf", url: "/_blob/a1b2" }, + }, + "POST /api/packets/1/pages": { body: makePacket({ version: 4, drawing_state: "hand" }) }, + }); + renderApp("/packets/1"); + const input = await screen.findByLabelText("Attach drawing (PDF)"); + const file = new File(["%PDF-1.4\n"], "d.pdf", { type: "application/pdf" }); + fireEvent.change(input, { target: { files: [file] } }); + + const sheet = await screen.findByRole("dialog", { name: "Sign" }); + expect(within(sheet).getByText("Attach a drawing by hand")).toBeInTheDocument(); + expect(within(sheet).getByText(/You are signing as/)).toHaveTextContent("You are signing as Clara Clerk."); + expect(callsTo(fetch, "POST", "/api/assets")).toHaveLength(1); + fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "pw-123" } }); + fireEvent.click(within(sheet).getByRole("button", { name: "Sign" })); + + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sign" })).not.toBeInTheDocument()); + expect(callsTo(fetch, "POST", "/api/packets/1/pages")).toEqual([ + { asset_id: "a1b2", type: "drawing", dwg: "CI-DWG-1", rev: "0", sign: { factor: "password", secret: "pw-123" } }, + ]); + expect(await screen.findByText("Drawing attached (1 page).")).toBeInTheDocument(); + }); + + it("a wrong signature keeps the sheet open with the attempts left", async () => { + mockFetch({ + ...base(), + "POST /api/assets": { status: 201, body: { id: "a1", mime: "application/pdf", size: 9, sha256: "00", pages: 2, original_name: "d.pdf", url: "/_blob/a1" } }, + "POST /api/packets/1/pages": { status: 403, body: { detail: "Wrong password.", code: "wrong_secret", attempts_left: 2 } }, + }); + renderApp("/packets/1"); + fireEvent.change(await screen.findByLabelText("Attach drawing (PDF)"), { target: { files: [new File(["%PDF"], "d.pdf")] } }); + const sheet = await screen.findByRole("dialog", { name: "Sign" }); + fireEvent.change(within(sheet).getByLabelText("Password"), { target: { value: "nope" } }); + fireEvent.click(within(sheet).getByRole("button", { name: "Sign" })); + expect(await within(sheet).findByText("Wrong password.")).toBeInTheDocument(); + expect(within(sheet).getByText("2 attempts left.")).toBeInTheDocument(); + expect(within(sheet).getByText("Three wrong attempts end the session.")).toBeInTheDocument(); + }); + + it("a stale step shows the server message and reloads the packet", async () => { + let gets = 0; + const fetch = mockFetch({ + ...base(), + "/api/packets/1": () => { + gets++; + return gets === 1 + ? { body: makePacket({ heat_map: true, can: ["send_to_warehouse", "edit_header"], blocked: {} }) } + : { body: makePacket({ status: "warehouse", heat_map: true, can: [], blocked: {}, holder: "Warehouse" }) }; + }, + "POST /api/packets/1/transitions": { + status: 409, + body: { detail: "Someone else already took this step.", code: "stale_step", current: { status: "warehouse", dept: "03", version: 5 } }, + }, + }); + renderApp("/packets/1"); + fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Someone else already took this step."); + await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2)); + expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse"); + }); + + it("asks an admin for the IT reason and retries with it", async () => { + const admin = makeMe({ roles: ["admin"], screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"] }); + let tries = 0; + const fetch = mockFetch({ + ...base(makePacket({ heat_map: true, can: ["send_to_warehouse"], blocked: {} })), + "/api/auth/me": { body: admin }, + "POST /api/packets/1/transitions": () => + ++tries === 1 + ? { status: 400, body: { detail: "Give an IT reason.", code: "it_reason_required" } } + : { body: makePacket({ status: "warehouse", can: [], blocked: {} }) }, + }); + renderApp("/packets/1"); + fireEvent.click(await screen.findByRole("button", { name: "Send to warehouse" })); + const sheet = await screen.findByRole("dialog", { name: "IT reason" }); + fireEvent.change(within(sheet).getByLabelText("Why IT is making this change"), { target: { value: "Clerk is out sick today" } }); + fireEvent.click(within(sheet).getByRole("button", { name: "Continue" })); + await waitFor(() => expect(screen.getByTestId("status-pill")).toHaveTextContent("With warehouse")); + expect(callsTo(fetch, "POST", "/api/packets/1/transitions")).toEqual([ + { action: "send_to_warehouse", expect: { status: "draft", dept: "03" } }, + { action: "send_to_warehouse", expect: { status: "draft", dept: "03" }, it_reason: "Clerk is out sick today" }, + ]); + }); + + it("shows View only with the holder when the user has no actions", async () => { + mockFetch({ + ...base(makePacket({ can: [], blocked: {}, holder: "Clerical" })), + "/api/auth/me": { body: makeMe({ roles: ["warehouse"], screens: ["warehouse"] }) }, + }); + renderApp("/packets/1"); + const note = (await screen.findByText("View only.")).closest(".viewonly") as HTMLElement; + expect(note).toHaveTextContent("This packet is with Clerical."); + expect(screen.queryByRole("button", { name: /Send/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("group", { name: "Heat map required?" })).not.toBeInTheDocument(); + }); + + it("reloads when a changed event names this packet", async () => { + const sources: Array<{ emit: (t: string, d: unknown) => void }> = []; + class FakeEventSource { + listeners = new Map void>(); + constructor() { + sources.push(this); + } + addEventListener(type: string, fn: (ev: MessageEvent) => void) { + this.listeners.set(type, fn); + } + close() {} + emit(type: string, data: unknown) { + this.listeners.get(type)?.(new MessageEvent(type, { data: JSON.stringify(data) })); + } + } + vi.stubGlobal("EventSource", FakeEventSource); + const fetch = mockFetch(base()); + renderApp("/packets/1"); + await screen.findByRole("heading", { name: "L999001 · WELD-PART" }); + expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(1); + vi.useFakeTimers(); + try { + act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 2, version: 9 })); + act(() => sources.at(-1)?.emit("changed", { event: "changed", packet_id: 1, version: 9 })); + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + } finally { + vi.useRealTimers(); + } + await waitFor(() => expect(callsTo(fetch, "GET", "/api/packets/1")).toHaveLength(2)); + }); +}); diff --git a/frontend/src/packet/PacketPage.tsx b/frontend/src/packet/PacketPage.tsx new file mode 100644 index 0000000..300dcd1 --- /dev/null +++ b/frontend/src/packet/PacketPage.tsx @@ -0,0 +1,554 @@ +import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; +import { Link, useNavigate, useParams } from "react-router"; +import { assetsApi } from "../api/assets"; +import { ApiError } from "../api/client"; +import { packetsApi } from "../api/packets"; +import type { PacketHeader, PacketRead, TransitionBody } from "../api/types"; +import { homePath } from "../auth/roles"; +import { useAuth } from "../auth/useAuth"; +import { useGuard } from "../components/useGuard"; +import { useChanged } from "../lib/changes"; +import { errorText } from "../lib/format"; +import { dueInfo, flagClass, jobName, pillClass, statusLabel, targetHours } from "../lib/packets"; +import { DrawingViewer } from "./DrawingViewer"; +import { MoveSheet, SendToDeptSheet } from "./sheets"; +import { DocumentsTab, HistoryTab, MaterialsTab, WorkOrdersTab, type Mutate } from "./tabs"; + +type Tab = "drawing" | "materials" | "workorders" | "docs" | "history"; + +/** The rule's signature meaning for attaching a drawing by hand. */ +const SIGN_ATTACH = "Attach a drawing by hand"; +const SIGN_FILE = "File in the data book"; + +/** One step button. Hidden when the user can neither do it nor is blocked from it. Blocked shows the reason under it. */ +function Act({ + p, + action, + busy, + onClick, + className = "btn", + children, +}: { + p: PacketRead; + action: string; + busy: boolean; + onClick: () => void; + className?: string; + children: ReactNode; +}) { + const can = p.can.includes(action); + const why = p.blocked[action]; + if (!can && !why) return null; + const reason = why?.join(" ") ?? ""; + return ( +
+ + {!can && reason ?
{reason}
: null} +
+ ); +} + +/** Title, customer, WPS, drawing and rev, edited in place with a field-level base. */ +function HeaderEdit({ p, mutate, busy, onDone }: { p: PacketRead; mutate: Mutate; busy: boolean; onDone: () => void }) { + const head = p.can.includes("edit_header"); + const dwg = p.can.includes("edit_drawing_ref"); + const [v, setV] = useState({ title: p.title ?? "", customer: p.customer ?? "", wps: p.wps ?? "", drawing: p.drawing ?? "", rev: p.rev ?? "" }); + const field = (k: keyof typeof v, label: string, max: number, on: boolean) => + on ? ( + + ) : null; + + const save = async (e: FormEvent) => { + e.preventDefault(); + const set: Partial = {}; + const base: Partial = {}; + for (const k of Object.keys(v) as Array) { + const now = v[k].trim() || null; + const was = p[k] ?? null; + if (now !== was) { + set[k] = now; + base[k] = was; + } + } + if (Object.keys(set).length === 0) { + onDone(); + return; + } + if (await mutate((g) => packetsApi.patch(p.id, { set, base, ...g }), { ok: "Saved" })) onDone(); + }; + + return ( +
+
+ {field("title", "Title", 200, head)} + {field("customer", "Customer", 120, head)} + {field("wps", "WPS", 40, head)} + {field("drawing", "Drawing no.", 60, dwg)} + {field("rev", "Rev", 10, dwg)} +
+
+ + +
+
+ ); +} + +/** /packets/:id: the packet for every signed-in role. Controls come from `can` and `blocked`. */ +export function PacketPage() { + const params = useParams(); + const id = Number(params.id); + const navigate = useNavigate(); + const { me } = useAuth(); + const { run, askReason, sheet: guardSheet } = useGuard(); + const [packet, setPacket] = useState(null); + const [loadError, setLoadError] = useState(null); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + const [busy, setBusy] = useState(false); + const [tab, setTab] = useState(null); + const [sheet, setSheet] = useState<"dept" | "move" | null>(null); + const [editing, setEditing] = useState(false); + const versionRef = useRef(null); + const fileRef = useRef(null); + + const reload = useCallback(async () => { + try { + const p = await packetsApi.get(id); + setPacket(p); + setLoadError(null); + } catch (err) { + setLoadError(errorText(err)); + } + }, [id]); + + useEffect(() => { + setPacket(null); + setTab(null); + void reload(); + }, [reload]); + + useEffect(() => { + versionRef.current = packet?.version ?? null; + }, [packet]); + + // Live refresh: another screen changed this packet. + useChanged(() => void reload(), { + delayMs: 500, + filter: (ev) => Number(ev.packet_id) === id && (ev.version === undefined || ev.version !== versionRef.current), + }); + + const job = packet ? jobName(packet) : ""; + + const mutate: Mutate = useCallback( + async (fn, opts = {}) => { + setBusy(true); + setMsg(null); + 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); + else await reload(); + if (opts.ok) setMsg({ ok: true, text: opts.ok }); + return true; + } catch (err) { + // stale_step, field_conflict, row_conflict: someone else moved first. Show their state. + if (err instanceof ApiError && (err.status === 409 || err.status === 412)) await reload(); + setMsg({ ok: false, text: errorText(err) }); + return false; + } finally { + setBusy(false); + } + }, + [run, reload, job], + ); + + if (!packet) { + return loadError ?
{loadError}
:
Loading
; + } + + const p = packet; + const can = (a: string) => p.can.includes(a); + const draft = p.status === "draft"; + const transition = (action: string, extra: Omit = {}, ok?: string) => + mutate((g) => packetsApi.transition(p.id, { action, expect: { status: p.status, dept: p.dept.code }, ...extra, ...g }), { ok }); + + const setHeatMap = (v: boolean) => + mutate((g) => packetsApi.patch(p.id, { set: { heat_map: v }, base: { heat_map: p.heat_map }, ...g })); + + const attach = async (file: File) => { + setBusy(true); + setMsg(null); + let asset; + try { + asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "drawing" }); + } catch (err) { + setMsg({ ok: false, text: errorText(err) }); + setBusy(false); + return; + } finally { + if (fileRef.current) fileRef.current.value = ""; + } + setBusy(false); + if (asset.mime !== "application/pdf") { + setMsg({ ok: false, text: "The drawing must be a PDF." }); + return; + } + const pages = asset.pages ?? 0; + await mutate( + (g) => + packetsApi.attachPages(p.id, { + asset_id: asset.id, + type: "drawing", + ...(p.drawing ? { dwg: p.drawing } : {}), + ...(p.rev ? { rev: p.rev } : {}), + ...g, + }), + { sign: SIGN_ATTACH, ok: pages ? `Drawing attached (${pages} page${pages === 1 ? "" : "s"}).` : "Drawing attached." }, + ); + }; + + const sendToQc = async () => { + const note = await askReason({ + title: "Send straight to Q.C.", + sub: job, + label: "Note (optional, Q.C. will see it)", + note: "Skips the warehouse and the shop. Q.C. gets it for inspection.", + min: 0, + submitLabel: "Send to Q.C.", + }); + if (note === null) return; + await transition("send_draft_to_qc", note ? { note } : {}, "Sent to Q.C."); + }; + + const remove = async () => { + const reason = await askReason({ + title: "Remove packet", + sub: job, + note: "The packet leaves every board. It stays in the record with your reason.", + min: 8, + danger: true, + submitLabel: "Remove packet", + }); + if (reason === null) return; + setBusy(true); + setMsg(null); + try { + const r = await run((g) => packetsApi.remove(p.id, { reason, ...g }), { sub: job }); + if (r !== undefined) navigate(`/clerical/units/${p.unit.id}`); + } catch (err) { + if (err instanceof ApiError && err.status === 409) await reload(); + setMsg({ ok: false, text: errorText(err) }); + } finally { + setBusy(false); + } + }; + + const removeDrawing = async (n: number) => { + await mutate((g) => packetsApi.removePages(p.id, n, g), { ok: "Drawing removed." }); + }; + + const fileIt = () => mutate((g) => packetsApi.transition(p.id, { action: "file", expect: { status: p.status, dept: p.dept.code }, ...g }), { sign: SIGN_FILE, ok: "Filed in the data book." }); + + const drawingPages = p.pages.filter((x) => x.type === "drawing"); + const shownTab: Tab = tab ?? (drawingPages.length ? "drawing" : "materials"); + // The warehouse short-parts exception and the data book date are not edits of the packet itself. + const passive = new Set(["wh_receive_short", "set_databook_at"]); + const viewOnly = + p.can.every((a) => passive.has(a)) && Object.keys(p.blocked).every((a) => passive.has(a)); + const project = p.unit.project; + const di = dueInfo(p.unit.due_effective, project.status === "closed"); + 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 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]); + + const tabs: Array<{ id: Tab; label: string; count?: number }> = [ + { id: "drawing", label: "Drawing", count: drawingPages.length }, + { id: "materials", label: "Materials", count: p.bom.length }, + { id: "workorders", label: "Work orders", count: p.work_orders.length }, + { id: "docs", label: "Documents", count: p.docs.length }, + { id: "history", label: "History" }, + ]; + + return ( + <> + + ← Back + +
+
+
+ {p.op ?? ""} · {statusLabel(p.status)} +
+

{job}

+ {p.title ?
{p.title}
: null} +
+ Project {project.number} {project.name ?? ""} › Unit {p.unit.number} {p.unit.name ?? ""} + {project.po ? ( + <> + {" "} + · PO {project.po} + + ) : null}{" "} + · {di.txt} +
+
+ {lotQty ? ( +
+ Lot qty + {lotQty} +
+ ) : null} +
+ Customer + {p.customer || "-"} +
+
+ Drawing + {p.drawing ? `${p.drawing} R${p.rev || "-"}` : "-"} +
+
+ WPS + {p.wps || "-"} +
+
+ Department + + {p.dept.code} {p.dept.name} + +
+
+ Target + {target.toFixed(1)} h +
+
+ Heat map + {p.heat_map === true ? "Yes" : p.heat_map === false ? "Not required" : "Not answered"} +
+ {p.for_sup ? ( +
+ For + {p.for_sup.display_name} +
+ ) : null} + {p.station ? ( +
+ Station + {p.station.name} +
+ ) : null} + {p.welder ? ( +
+ Welder + + {p.welder.display_name} + {p.welder.stamp ? ` [${p.welder.stamp}]` : ""} + +
+ ) : null} +
+
+
+ + {statusLabel(p.status)} + + {can("edit_header") || can("edit_drawing_ref") ? ( + + ) : null} + {can("move_unit") ? ( + + ) : null} +
+
+ + {p.flag ?
⚑ {p.flag}
: null} + {p.removed ? ( +
+ Removed + {p.removed.by ? ` by ${p.removed.by.display_name}` : ""}. {p.removed.reason ?? ""} +
+ ) : null} + {p.return_note ? ( +
+ Returned: {p.return_note} +
+ ) : null} + {viewOnly && !p.removed ? ( +
+ +
+ View only. This packet is with {p.holder}. You can follow its progress. Changes open up only when it is sent to you or sent back. +
+
+ ) : null} + {msg ? ( +
+ {msg.text} +
+ ) : null} + + {editing ? setEditing(false)} /> : null} + + {hasDraftControls ? ( +
+
+ Ready to release. Check the BOM and work orders, attach the drawing, and say whether it needs a heat map. Then send it to the warehouse for the + parts pull, or straight to a department or Q.C. +
+ {can("edit_header") ? ( +
+ Heat map required? + + + {p.heat_map === null ? choose before sending : null} +
+ ) : null} +
+ {can("edit_document_set") ? ( +
+ +
+ ) : null} + void transition("send_to_warehouse", {}, "Sent to the warehouse.")}> + Send to warehouse + + setSheet("dept")}> + Send to a department + + void sendToQc()}> + Send to Q.C. + + void remove()}> + Remove + +
+
+ ) : null} + + {!draft && (can("set_vclosed") || can("file") || p.blocked.file) ? ( +
+
+ {p.vclosed ? ( + <> + Ready for the data book. Job closed in VISUAL by {p.vclosed.by?.display_name ?? "Clerical"}. Mark it filed when the data book is done. + + ) : ( + <> + Back from the shop, ready to close. Close the job in VISUAL, then click below. The packet moves on to the data book. + + )} +
+
+ {!p.vclosed ? ( + void mutate((g) => packetsApi.setVclosed(p.id, true, g), { ok: "Marked closed in VISUAL." })}> + Job closed in VISUAL + + ) : ( + void mutate((g) => packetsApi.setVclosed(p.id, false, g))}> + Not closed in VISUAL, move back + + )} + void fileIt()}> + Mark filed + +
+
+ ) : null} + +
+ {tabs.map((t) => ( + + ))} +
+
+ {shownTab === "drawing" ? ( +
+ {draft && can("edit_document_set") && drawingPages.length ? ( +
+ + {drawingPages.length} drawing page{drawingPages.length === 1 ? "" : "s"} attached by hand. + + + +
+ ) : null} + +
+ ) : null} + {shownTab === "materials" ? : null} + {shownTab === "workorders" ? : null} + {shownTab === "docs" ? : null} + {shownTab === "history" ? : null} +
+ + {sheet === "dept" ? ( + setSheet(null)} + onSend={(dept, note) => { + setSheet(null); + void transition("release_to_shop", { dept, ...(note ? { note } : {}) }, "Sent to the department."); + }} + /> + ) : null} + {sheet === "move" ? ( + setSheet(null)} + onMove={(unitId, reason) => { + setSheet(null); + void mutate((g) => packetsApi.move(p.id, { unit_id: unitId, ...(reason ? { reason } : {}), ...g }), { ok: "Packet moved." }); + }} + /> + ) : null} + {guardSheet} + + ); +} diff --git a/frontend/src/packet/sheets.tsx b/frontend/src/packet/sheets.tsx new file mode 100644 index 0000000..7efb086 --- /dev/null +++ b/frontend/src/packet/sheets.tsx @@ -0,0 +1,117 @@ +import { useState, type FormEvent } from "react"; +import type { PacketRead } from "../api/types"; +import { PickUnit, type UnitPick } from "../components/PickUnit"; +import { Sheet } from "../components/Sheet"; +import { useShopDepartments } from "../lib/useDepartments"; + +/** Clerical: send a draft straight to a shop department. Skips the warehouse parts pull. */ +export function SendToDeptSheet({ + p, + sub, + onSend, + onCancel, +}: { + p: PacketRead; + sub: string; + onSend: (dept: string, note: string) => void; + onCancel: () => void; +}) { + const depts = useShopDepartments(); + const [dept, setDept] = useState(p.dept.code); + const [note, setNote] = useState(""); + const value = depts.some((d) => d.code === dept) ? dept : (depts[0]?.code ?? ""); + + const submit = (e: FormEvent) => { + e.preventDefault(); + if (value) onSend(value, note.trim()); + }; + + return ( + +
Skips the warehouse parts pull. The department's lead or supervisor gets it ready to assign.
+
+ + +
+ + +
+
+
+ ); +} + +/** Clerical: move a packet to another unit, through the two-step picker. */ +export function MoveSheet({ + p, + sub, + onMove, + onCancel, +}: { + p: PacketRead; + sub: string; + onMove: (unitId: number, reason: string) => void; + onCancel: () => void; +}) { + const [pick, setPick] = useState({ + project: { id: p.unit.project.id, number: p.unit.project.number, name: p.unit.project.name, customer: p.unit.project.customer, po: p.unit.project.po, status: p.unit.project.status }, + unit: null, + }); + const [reason, setReason] = useState(""); + const [error, setError] = useState(null); + + const submit = (e: FormEvent) => { + e.preventDefault(); + if (!pick.unit) { + setError("Pick the unit to move it to."); + return; + } + if (pick.unit.id === p.unit.id) { + setError("The packet is in that unit already."); + return; + } + onMove(pick.unit.id, reason.trim()); + }; + + return ( + +
+ + +
The move is recorded in the packet history and the access log.
+ {error ? ( +
+ {error} +
+ ) : null} +
+ + +
+ +
+ ); +} diff --git a/frontend/src/packet/tabs.tsx b/frontend/src/packet/tabs.tsx new file mode 100644 index 0000000..63fbebb --- /dev/null +++ b/frontend/src/packet/tabs.tsx @@ -0,0 +1,466 @@ +import QRCode from "qrcode"; +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { assetsApi } from "../api/assets"; +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"; + +export type Mutate = ( + fn: (g: Guarded) => Promise, + opts?: { sign?: string | undefined; ok?: string | undefined }, +) => Promise; + +const show = (v: unknown) => (v === null || v === undefined || v === "" ? "-" : String(v)); + +/* ---------- Materials ---------- */ + +interface LineDraft { + qty: string; + part_id: string; + description: string; + material: string; + length: string; + dwg_ref: string; +} + +const toDraft = (b: BomLine): LineDraft => ({ + qty: b.qty === null || b.qty === undefined ? "" : String(b.qty), + part_id: b.part_id ?? "", + description: b.description ?? "", + material: b.material ?? "", + length: b.length ?? "", + dwg_ref: b.dwg_ref ?? "", +}); + +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 }) { + const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom"); + const [lines, setLines] = useState(null); + 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) => { + e.preventDefault(); + if (!lines) return; + const bom: BomLine[] = lines + .filter((l) => l.part_id.trim() || l.description.trim()) + .map((l, i) => ({ + item: i + 1, + qty: l.qty.trim() === "" ? null : Number(l.qty), + part_id: l.part_id.trim() || null, + description: l.description.trim() || null, + material: l.material.trim() || null, + length: l.length.trim() || null, + dwg_ref: l.dwg_ref.trim() || null, + })); + const ok = await mutate((g) => packetsApi.putBom(p.id, p.version, bom, g), { ok: "BOM saved" }); + if (ok) setLines(null); + }; + + if (lines) { + return ( +
+
+ + + + + + + + + + + + + + {lines.map((l, i) => ( + + + + + + + + + + + ))} + +
ItemQtyPart IDDescriptionMaterialLengthDwg ref +
{i + 1} + set(i, "qty", e.target.value)} /> + + set(i, "part_id", e.target.value)} /> + + set(i, "description", e.target.value)} /> + + set(i, "material", e.target.value)} /> + + set(i, "length", e.target.value)} /> + + set(i, "dwg_ref", e.target.value)} /> + + +
+
+
+ + + + +
+
+ ); + } + + return ( +
+ {editable ? ( +
+ This packet was made by hand. Clerical can change its lines while it is a draft. + + +
+ ) : null} + {p.bom.length ? ( +
+ + + + + + + + + + + + + + + {p.bom.map((b) => ( + + + + + + + + + + + ))} + +
ItemQtyPart IDDescriptionMaterialLengthHeatPulled
+ {b.item} + {show(b.qty)}{show(b.part_id)} + {show(b.description)} + {b.dwg_ref ?
Dwg ref {b.dwg_ref}
: null} +
{show(b.material)}{show(b.length)}{show(b.heat)} + {b.na ? ( + N/A + ) : b.short ? ( + {String(b.short.qty_open)} short + ) : b.pulled ? ( + Pulled + ) : ( + Not yet + )} +
+
+ ) : ( +
No BOM lines on this packet.
+ )} +
+ ); +} + +/* ---------- Work orders ---------- */ + +function woKey(w: Pick): string { + return `${w.wo_base}/${w.lot}/${w.split}/${w.sub}`; +} + +function Qr({ text }: { text: string }) { + const ref = useRef(null); + const [failed, setFailed] = useState(false); + useEffect(() => { + const c = ref.current; + if (!c) return; + let ok = true; + try { + QRCode.toCanvas(c, text, { width: 128, margin: 1, errorCorrectionLevel: "M" }).catch(() => { + if (ok) setFailed(true); + }); + } catch { + setFailed(true); + } + return () => { + ok = false; + }; + }, [text]); + return ( +
+ {failed ? No QR : } +
+ ); +} + +/** The work orders, and one QR code per work order key. */ +export function WorkOrdersTab({ p }: { p: PacketRead }) { + if (!p.work_orders.length) return
No work orders on this packet.
; + return ( +
+
+ + + + + + + + + + + + + + + {p.work_orders.map((w) => ( + + + + + + + + + + + ))} + +
WOLotSplitSubSeqOperationQtyTarget hrs
{w.wo_base}{w.lot}{w.split}{w.sub}{show(w.seq)}{show(w.op)}{show(w.qty)}{show(w.target_hrs)}
+
+
+ {p.work_orders.map((w) => { + const k = woKey(w); + return ( +
+ +
+
{w.wo_base}
+
{k}
+
+
+ ); + })} +
+
+ ); +} + +/* ---------- Documents ---------- */ + +const DOC_KINDS: ReadonlyArray<[string, string]> = [ + ["mtr", "Material test report"], + ["nde", "NDE report"], + ["procedure", "Procedure (WPS / PQR)"], + ["photo", "Photo"], + ["other", "Other"], +]; + +const kindLabel = (k: string) => DOC_KINDS.find(([v]) => v === k)?.[1] ?? k; + +/** Documents attached to the packet. Clerical and Q.C. add and remove them. */ +export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) { + const canAdd = p.can.includes("doc_add"); + const canRemove = p.can.includes("doc_remove"); + const fileRef = useRef(null); + const [kind, setKind] = useState("mtr"); + const [title, setTitle] = useState(""); + const [file, setFile] = useState(null); + const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); + const [confirm, setConfirm] = useState(null); + + const add = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + if (!file) { + setError("Choose a file."); + return; + } + setUploading(true); + try { + const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "doc" }); + 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(""); + setFile(null); + if (fileRef.current) fileRef.current.value = ""; + } + } catch (err) { + setError(errorText(err)); + } finally { + setUploading(false); + } + }; + + return ( +
+ {p.docs.length ? ( +
+ + + + + + + + + + + {p.docs.map((d) => ( + + + + + + + + ))} + +
TitleKindAddedBy +
+ + {d.title || "Untitled"} + + {kindLabel(d.kind)}{fmtTime(d.added_at)}{d.added_by?.display_name ?? "-"} + {canRemove ? ( + confirm === d.id ? ( + <> + {" "} + + + ) : ( + + ) + ) : null} +
+
+ ) : ( +
No documents on this packet.
+ )} + {canAdd ? ( +
+

Add a document

+
+ + + +
+ {error ? ( +
+ {error} +
+ ) : null} +
+ +
+
+ ) : null} +
+ ); +} + +/* ---------- History ---------- */ + +/** GET /log, newest first. IT edits and signatures are marked. */ +export function HistoryTab({ p }: { p: PacketRead }) { + const [load] = useLoad((s) => packetsApi.log(p.id, { limit: 300 }, s), `${p.id}:${p.version}`); + if (load.state === "loading") return
Loading
; + if (load.state === "error") return
{load.message}
; + const rows = load.data; + if (!rows.length) return
Nothing recorded yet.
; + return ( +
+ + + + + + + + + + + {rows.map((r) => ( + + + + + + + ))} + +
WhenWhoWhatNote
{fmtTime(r.at)} + {r.by?.display_name ?? "System"} + {r.role ?
{r.role}
: null} +
+ {r.action} + {r.signed ? Signed : null} + {r.it_reason ? IT edit : null} + + {r.note ?? ""} + {r.it_reason ?
IT reason: {r.it_reason}
: null} +
+
+ ); +} diff --git a/frontend/src/screens/ScreenRoute.tsx b/frontend/src/screens/ScreenRoute.tsx index 4111adb..95c8c05 100644 --- a/frontend/src/screens/ScreenRoute.tsx +++ b/frontend/src/screens/ScreenRoute.tsx @@ -1,9 +1,19 @@ +import type { ReactNode } from "react"; import { Navigate, useParams } from "react-router"; -import { homePath, isScreen, orderScreens } from "../auth/roles"; +import { homePath, isScreen, orderScreens, type Screen } from "../auth/roles"; import { useAuth } from "../auth/useAuth"; import { AdminPage } from "./admin/AdminPage"; +import { ClericalHome } from "./clerical/ClericalHome"; import { PlaceholderScreen } from "./PlaceholderScreen"; +/** 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 }) { + const { me } = useAuth(); + if (!me) return null; + if (!orderScreens(me.screens).includes(screen)) return ; + return <>{children}; +} + /** /:screen. An unknown screen, or one the user does not have, goes to the user's first screen. */ export function ScreenRoute() { const { screen } = useParams(); @@ -12,5 +22,6 @@ export function ScreenRoute() { const allowed = orderScreens(me.screens); if (!isScreen(screen) || !allowed.includes(screen)) return ; if (screen === "admin") return ; + if (screen === "clerical") return ; return ; } diff --git a/frontend/src/screens/clerical/ClericalHome.test.tsx b/frontend/src/screens/clerical/ClericalHome.test.tsx new file mode 100644 index 0000000..314b329 --- /dev/null +++ b/frontend/src/screens/clerical/ClericalHome.test.tsx @@ -0,0 +1,108 @@ +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"; +import { makeCard, makeProject, makeUnit } from "../../test/packets"; + +const CARDS = [ + makeCard({ id: 1, flag: "Made by hand - check it and send it on" }), + makeCard({ id: 2, unit: { id: 12, number: "L999002" }, op: "FIT-UP" }), + makeCard({ id: 3, status: "warehouse", holder: "Warehouse" }), + makeCard({ id: 4, status: "in_progress" }), + makeCard({ id: 5, status: "complete", routed_to: "databook", unit: { id: 13, number: "L999003" } }), + makeCard({ id: 6, status: "complete", routed_to: "databook", vclosed: { at: "2026-10-15T10:00:00Z", by: null }, unit: { id: 14, number: "L999004" } }), + makeCard({ id: 7, status: "filed", unit: { id: 15, number: "L999005" } }), +]; + +function base() { + return { + "/api/auth/me": { body: makeMe() }, + "/api/departments": { body: DEPARTMENTS }, + "/api/packets": { body: { packets: CARDS } }, + "/api/projects": { body: [makeProject()] }, + }; +} + +function column(title: string): HTMLElement { + return screen.getByRole("heading", { name: new RegExp(`^${title}`) }).closest("section") as HTMLElement; +} + +describe("ClericalHome", () => { + it("renders the boards and the numbers from the list answer", async () => { + mockFetch(base()); + renderApp("/clerical"); + expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument(); + await screen.findByRole("heading", { name: /^Flagged/ }); + + expect(screen.getByLabelText("Flagged: 1")).toHaveClass("kpi", "hot"); + expect(screen.getByLabelText("With warehouse: 1")).toBeInTheDocument(); + expect(screen.getByLabelText("In the shop: 1")).toBeInTheDocument(); + expect(screen.getByLabelText("Ready to close: 1")).toHaveClass("hot"); + expect(screen.getByLabelText("Changes to approve: 0")).toHaveClass("zero"); + + expect(within(column("Changes to approve")).getByText("Arrives in phase 3 with the VISUAL feed.")).toBeInTheDocument(); + const flagged = column("Flagged"); + expect(within(flagged).getByText("L999001 · WELD-PART")).toBeInTheDocument(); + expect(within(flagged).getByText(/Made by hand/)).toBeInTheDocument(); + expect(within(column("Going to the warehouse")).getByText("L999002 · FIT-UP")).toBeInTheDocument(); + expect(within(column("Ready to close")).getByText("L999003 · WELD-PART")).toBeInTheDocument(); + expect(within(column("Ready for data book")).getByText("L999004 · WELD-PART")).toBeInTheDocument(); + const filed = column("Filed"); + expect(within(filed).getByText("L999005 · WELD-PART")).toBeInTheDocument(); + expect(within(filed).getByText("Filed in data book")).toHaveClass("pill", "st-filed"); + + // The list view holds the same packets. + fireEvent.click(screen.getByRole("button", { name: "List" })); + const table = await screen.findByRole("table"); + expect(within(table).getAllByRole("row")).toHaveLength(1 + 5); + }); + + it("the picker loads the units after a project is chosen", async () => { + const fetch = mockFetch({ + ...base(), + "/api/units": { body: [makeUnit(), makeUnit({ id: 12, number: "L999002", lot: "002" })] }, + }); + renderApp("/clerical"); + const bar = await screen.findByRole("region", { name: "Find jobs" }); + expect(callsTo(fetch, "GET", "/api/units")).toHaveLength(0); + fireEvent.click(await within(bar).findByRole("button", { name: /L999/ })); + + const unit = await within(bar).findByLabelText("Unit"); + await within(unit).findByRole("option", { name: "L999002" }); + const urls = fetch.mock.calls.map(([u]) => String(u)); + expect(urls).toContain("/api/units?project_id=7"); + // Choosing the project narrows the boards. + await waitFor(() => expect(urls.concat(fetch.mock.calls.map(([u]) => String(u)))).toContain("/api/packets?limit=500&project_id=7")); + + fireEvent.change(unit, { target: { value: "12" } }); + await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&unit_id=12")); + }); + + it("the new project sheet posts and then opens Add units", async () => { + const fetch = mockFetch({ + ...base(), + "POST /api/projects": { status: 201, body: makeProject({ id: 9, number: "L998", name: "New one", units: 0 }) }, + "POST /api/projects/9/units": { status: 201, body: [makeUnit({ id: 21, project_id: 9, number: "L998001" })] }, + "/api/projects/9": { body: makeProject({ id: 9, number: "L998", name: "New one", units: [makeUnit({ id: 21, project_id: 9, number: "L998001" })] }) }, + }); + renderApp("/clerical"); + fireEvent.click(await screen.findByRole("button", { name: "New project" })); + const sheet = await screen.findByRole("dialog", { name: "New project" }); + fireEvent.change(within(sheet).getByLabelText("Project number"), { target: { value: "l998" } }); + fireEvent.change(within(sheet).getByLabelText("Project name"), { target: { value: "New one" } }); + fireEvent.click(within(sheet).getByRole("button", { name: "Create project" })); + + const add = await screen.findByRole("dialog", { name: "Add units" }); + expect(callsTo(fetch, "POST", "/api/projects")).toEqual([{ number: "L998", name: "New one", customer: "", po: "", due: null, notes: "" }]); + expect(within(add).getByLabelText("Lot")).toHaveValue("001"); + + // Several units: a preview of the numbers. + fireEvent.click(within(add).getByRole("button", { name: "Several units" })); + fireEvent.change(within(add).getByLabelText("How many"), { target: { value: "3" } }); + expect(within(add).getByText("Numbers: L998001, L998002, L998003")).toBeInTheDocument(); + fireEvent.click(within(add).getByRole("button", { name: "Add" })); + + expect(await screen.findByRole("heading", { name: "L998 · New one" })).toBeInTheDocument(); + expect(callsTo(fetch, "POST", "/api/projects/9/units")).toEqual([{ lots: ["001", "002", "003"], name: "", due: null, notes: "" }]); + }); +}); diff --git a/frontend/src/screens/clerical/ClericalHome.tsx b/frontend/src/screens/clerical/ClericalHome.tsx new file mode 100644 index 0000000..958ff1b --- /dev/null +++ b/frontend/src/screens/clerical/ClericalHome.tsx @@ -0,0 +1,233 @@ +import { useEffect, useMemo, useState, type KeyboardEvent } from "react"; +import { useNavigate } from "react-router"; +import { packetsApi } from "../../api/packets"; +import type { PacketCard } from "../../api/types"; +import { useAuth } from "../../auth/useAuth"; +import { Board } from "../../components/Board"; +import { FindBar, type BoardView } from "../../components/FindBar"; +import { Kpis } from "../../components/Kpis"; +import type { UnitPick } from "../../components/PickUnit"; +import { canEditProjects } from "../../lib/access"; +import { useChanged } from "../../lib/changes"; +import { fmtTime } from "../../lib/format"; +import { STATUSES } from "../../api/types"; +import { dueInfo, flagClass, jobName, pillClass, statusLabel } from "../../lib/packets"; +import { useLoad } from "../../lib/useLoad"; +import { byTime, clericalBoards } from "./boards"; +import { ProjectsSection } from "./ProjectsSection"; +import { NewPacketSheet } from "./sheets"; + +type SortKey = "updated" | "unit" | "due" | "status" | "dept"; + +const SORTS: ReadonlyArray<[SortKey, string]> = [ + ["updated", "Last updated"], + ["unit", "Unit"], + ["due", "Due date"], + ["status", "Status"], + ["dept", "Department"], +]; + +function sorter(k: SortKey): (a: PacketCard, b: PacketCard) => number { + switch (k) { + case "unit": + return (a, b) => a.unit.number.localeCompare(b.unit.number, undefined, { numeric: true }) || (a.op ?? "").localeCompare(b.op ?? ""); + case "due": + return (a, b) => (a.due_effective ?? "9999").localeCompare(b.due_effective ?? "9999"); + case "status": + return (a, b) => STATUSES.indexOf(a.status) - STATUSES.indexOf(b.status); + case "dept": + return (a, b) => a.dept.code.localeCompare(b.dept.code); + default: + return (a, b) => byTime(b.updated_at, a.updated_at); + } +} + +function JobList({ rows, sort, onSort }: { rows: PacketCard[]; sort: SortKey; onSort: (k: SortKey) => void }) { + const navigate = useNavigate(); + const sorted = useMemo(() => [...rows].sort(sorter(sort)), [rows, sort]); + const onKey = (e: KeyboardEvent, id: number) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + navigate(`/packets/${id}`); + } + }; + return ( +
+
+

Jobs

+ + {sorted.length} packet{sorted.length === 1 ? "" : "s"} + + + +
+
+ + + + + + + + + + + + + + + {sorted.map((p) => { + const di = dueInfo(p.due_effective); + return ( + navigate(`/packets/${p.id}`)} onKeyDown={(e) => onKey(e, p.id)}> + + + + + + + + + + ); + })} + {sorted.length === 0 ? ( + + + + ) : null} + +
JobOperationDepartmentStatusDrawingDueUpdatedFlags
+ {jobName(p)} +
{p.project.name ?? ""}
+
+ {p.op ?? ""} +
{p.title ?? ""}
+
{p.dept.name} + {statusLabel(p.status)} + + {p.drawing ?? ""} {p.rev ? `R${p.rev}` : ""} + + {di.txt.replace(/^Due /, "")} + {fmtTime(p.updated_at)} + {p.open_shorts ? {p.open_shorts} short : null} + {p.flag ?
⚑ {p.flag}
: null} +
+ No packets match. +
+
+
+ ); +} + +/** /clerical: numbers, attention row, find bar, boards or list, then projects. */ +export function ClericalHome() { + const { me } = useAuth(); + const navigate = useNavigate(); + const [q, setQ] = useState(""); + const [query, setQuery] = useState(""); + const [pick, setPick] = useState({ project: null, unit: null }); + const [view, setView] = useState("board"); + const [sort, setSort] = useState("updated"); + const [drafting, setDrafting] = useState(false); + const projectId = pick.project?.id; + const unitId = pick.unit?.id; + + useEffect(() => { + const t = window.setTimeout(() => setQuery(q.trim()), 250); + return () => window.clearTimeout(t); + }, [q]); + + const [load, reload] = useLoad( + (s) => packetsApi.list({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s), + `${query}|${projectId ?? ""}|${unitId ?? ""}`, + ); + useChanged(reload, { delayMs: 500 }); + + const all = useMemo(() => (load.state === "ok" ? load.data : []), [load]); + const b = useMemo(() => clericalBoards(all), [all]); + const shown = useMemo(() => { + const ids = new Set(); + const out: PacketCard[] = []; + for (const c of b.columns) { + for (const p of c.cards) { + if (ids.has(p.id)) continue; + ids.add(p.id); + out.push(p); + } + } + return out; + }, [b]); + const editable = canEditProjects(me?.roles ?? []); + + return ( + <> +
+
+
Projects and job packets
+

Clerical

+
+ {editable ? ( +
+ +
+ ) : null} +
+ + + + {b.flagged || b.toClose ? ( +
+ {b.flagged ? ( +
+ {b.flagged === 1 ? "1 draft is flagged." : `${b.flagged} drafts are flagged.`} Check {b.flagged === 1 ? "it" : "them"} and send {b.flagged === 1 ? "it" : "them"} on. +
+ ) : null} + {b.toClose ? ( +
+ {b.toClose === 1 ? "1 packet is" : `${b.toClose} packets are`} back from the shop. Close the job in VISUAL, then mark it closed on the packet. +
+ ) : null} +
+ ) : null} + + + + {load.state === "loading" ?
Loading
: null} + {load.state === "error" ?
{load.message}
: null} + {load.state === "ok" ? view === "board" ? : : null} + + + + {drafting ? ( + setDrafting(false)} + onCreated={(p) => { + setDrafting(false); + navigate(`/packets/${p.id}`); + }} + /> + ) : null} + + ); +} diff --git a/frontend/src/screens/clerical/ProjectPage.tsx b/frontend/src/screens/clerical/ProjectPage.tsx new file mode 100644 index 0000000..8dd5cf6 --- /dev/null +++ b/frontend/src/screens/clerical/ProjectPage.tsx @@ -0,0 +1,290 @@ +import { useEffect, useState, type KeyboardEvent } from "react"; +import { Link, useNavigate, useParams } from "react-router"; +import { projectsApi } from "../../api/projects"; +import type { Project, Unit } from "../../api/types"; +import { useAuth } from "../../auth/useAuth"; +import { Kpis } from "../../components/Kpis"; +import { useGuard } from "../../components/useGuard"; +import { canEditProjects, canRenumber } from "../../lib/access"; +import { useChanged } from "../../lib/changes"; +import { dueInfo, PROJECT_STATUS_LABEL, stageCounts } from "../../lib/packets"; +import { useLoad } from "../../lib/useLoad"; +import { ProgressBar, ProjectPill } from "./common"; +import { AddUnitsSheet, ProjectSheet, RenumberSheet } from "./sheets"; + +type UnitShow = "active" | "hold" | "closed" | "all"; + +const UNIT_SHOWS: ReadonlyArray<[UnitShow, string]> = [ + ["active", "Active"], + ["hold", "On hold"], + ["closed", "Closed"], + ["all", "All"], +]; + +function unitsOf(p: Project): Unit[] { + return Array.isArray(p.units) ? [...p.units].sort((a, b) => a.number.localeCompare(b.number, undefined, { numeric: true })) : []; +} + +function sumCounts(units: Unit[]) { + const c = stageCounts(null); + for (const u of units) { + const x = stageCounts(u.packets); + c.draft += x.draft; + c.warehouse += x.warehouse; + c.shop += x.shop; + c.qc += x.qc; + c.complete += x.complete; + c.filed += x.filed; + c.total += x.total; + } + return c; +} + +function UnitTable({ project, units }: { project: Project; units: Unit[] }) { + const navigate = useNavigate(); + const [q, setQ] = useState(""); + const [show, setShow] = useState("active"); + const t = q.trim().toLowerCase(); + const rows = units.filter( + (u) => + (show === "all" || (show === "active" ? u.status !== "closed" : u.status === show)) && + (!t || [u.number, u.lot, u.name ?? "", u.notes ?? ""].join(" ").toLowerCase().includes(t)), + ); + const onKey = (e: KeyboardEvent, id: number) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + navigate(`/clerical/units/${id}`); + } + }; + return ( + <> +
+ +
+ {UNIT_SHOWS.map(([k, l]) => ( + + ))} +
+ + {rows.length} of {units.length} unit{units.length === 1 ? "" : "s"} + +
+
+ + + + + + + + + + + + + {rows.map((u) => { + const c = stageCounts(u.packets); + const own = !!u.due; + const di = dueInfo(u.due_effective ?? project.due, u.status === "closed" || project.status === "closed"); + return ( + navigate(`/clerical/units/${u.id}`)} onKeyDown={(e) => onKey(e, u.id)}> + + + + + + + + ); + })} + {rows.length === 0 ? ( + + + + ) : null} + +
UnitDescriptionStatusDuePacketsProgress
+ {u.number} + {u.name || "-"} + + + {di.txt.replace(/^Due /, "")} + {own ? null : (project)} + + {c.total} + {c.total ? · {c.filed} filed : null} + + +
+ No units match. +
+
+ + ); +} + +/** /clerical/projects/:projectId. */ +export function ProjectPage() { + const { projectId } = useParams(); + const id = Number(projectId); + const { me } = useAuth(); + const navigate = useNavigate(); + const guard = useGuard(); + const [load, reload] = useLoad((s) => projectsApi.get(id, s), String(id)); + const [sheet, setSheet] = useState<"edit" | "units" | "renumber" | null>(null); + const [toast, setToast] = useState(null); + const roles = me?.roles ?? []; + useChanged(reload, { delayMs: 500 }); + + useEffect(() => { + if (!toast) return; + const t = window.setTimeout(() => setToast(null), 2600); + return () => window.clearTimeout(t); + }, [toast]); + + const back = ( + + ← All projects + + ); + if (load.state === "loading") return <>{back}
Loading
; + if (load.state === "error") return <>{back}
{load.message}
; + + const p = load.data; + const units = unitsOf(p); + const c = p.packets ? stageCounts(p.packets) : sumCounts(units); + const di = dueInfo(p.due, p.status === "closed"); + const editable = canEditProjects(roles); + const aliases = (p.aliases ?? []).map((a) => (typeof a === "string" ? a : a.alias)).filter((a) => a && a !== p.number); + + return ( + <> + {back} +
+
+
Project · {PROJECT_STATUS_LABEL[p.status]}
+

+ {p.number} + {p.name ? ` · ${p.name}` : ""} +

+
+
+ Customer + {p.customer || "-"} +
+
+ Customer PO + {p.po || "-"} +
+
+ Due + {di.txt.replace(/^Due /, "")} +
+
+ Units + {units.length} +
+
+ Packets + {c.total} +
+ {aliases.length ? ( +
+ Old numbers + {aliases.join(", ")} +
+ ) : null} +
+ {p.notes ?
{p.notes}
: null} +
+
+ + {editable ? ( + + ) : null} + {canRenumber(roles) ? ( + + ) : null} +
+
+ + +
+
+

Units

+ each unit holds the packets for that piece of equipment + + {editable && p.status !== "closed" ? ( + + ) : null} +
+ {p.status === "closed" ?
This project is closed. Reopen it (Edit project) to add units or packets.
: null} + {units.length ? :
No units yet.{editable ? " Add one to start adding packets." : ""}
} +
+ + {sheet === "edit" ? ( + setSheet(null)} + onSaved={() => { + setSheet(null); + setToast("Project saved"); + reload(); + }} + /> + ) : null} + {sheet === "units" ? ( + u.lot)} + onCancel={() => setSheet(null)} + onDone={(added) => { + setSheet(null); + setToast(added.length === 1 ? `Unit ${added[0]?.number ?? ""} added` : `${added.length} units added`); + reload(); + }} + /> + ) : null} + {sheet === "renumber" ? ( + setSheet(null)} + onSave={async (number, reason) => { + const r = await guard.run((g) => projectsApi.renumber(p.id, { number, reason, ...g }), { sub: p.number }); + if (r === undefined) return; + setSheet(null); + setToast(`Project renumbered to ${number}`); + reload(); + if (r.id && r.id !== p.id) navigate(`/clerical/projects/${r.id}`); + }} + /> + ) : null} + {guard.sheet} + {toast ? ( +
+ {toast} +
+ ) : null} + + ); +} diff --git a/frontend/src/screens/clerical/ProjectsSection.tsx b/frontend/src/screens/clerical/ProjectsSection.tsx new file mode 100644 index 0000000..5fdba03 --- /dev/null +++ b/frontend/src/screens/clerical/ProjectsSection.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { projectsApi, type ProjectShow } from "../../api/projects"; +import type { Project } from "../../api/types"; +import { useAuth } from "../../auth/useAuth"; +import { canEditProjects } from "../../lib/access"; +import { useChanged } from "../../lib/changes"; +import { useLoad } from "../../lib/useLoad"; +import { ProjectCard } from "./common"; +import { AddUnitsSheet, ProjectSheet } from "./sheets"; + +const SHOWS: ReadonlyArray<[ProjectShow, string]> = [ + ["active", "Active"], + ["open", "Open"], + ["hold", "On hold"], + ["closed", "Closed"], + ["all", "All"], +]; + +/** Projects on the Clerical home: find box, status chips, cards, New project then Add units. */ +export function ProjectsSection() { + const { me } = useAuth(); + const navigate = useNavigate(); + const [show, setShow] = useState("active"); + const [q, setQ] = useState(""); + const [query, setQuery] = useState(""); + const [creating, setCreating] = useState(false); + const [adding, setAdding] = useState(null); + const [load, reload] = useLoad((s) => projectsApi.list(query, show, s), `${show}|${query}`); + const editable = canEditProjects(me?.roles ?? []); + + useEffect(() => { + const t = window.setTimeout(() => setQuery(q.trim()), 250); + return () => window.clearTimeout(t); + }, [q]); + + useChanged(reload, { delayMs: 500 }); + + const list = load.state === "ok" ? load.data : []; + return ( +
+
+

Projects

+ project, then units, then packets + +
+ {SHOWS.map(([k, l]) => ( + + ))} +
+ {editable ? ( + + ) : null} +
+
+ +
+ {load.state === "loading" ?
Loading
: null} + {load.state === "error" ?
{load.message}
: null} + {load.state === "ok" ? ( + list.length ? ( +
+ {list.map((p) => ( + + ))} +
+ ) : ( +
{query ? `Nothing matches "${query}".` : "No projects in this view. Set one up, add its units, then add packets into each unit."}
+ ) + ) : null} + {creating ? ( + setCreating(false)} + onSaved={(p) => { + setCreating(false); + setAdding(p); + reload(); + }} + /> + ) : null} + {adding ? ( + setAdding(null)} + onDone={() => { + const id = adding.id; + setAdding(null); + navigate(`/clerical/projects/${id}`); + }} + /> + ) : null} +
+ ); +} diff --git a/frontend/src/screens/clerical/UnitPage.tsx b/frontend/src/screens/clerical/UnitPage.tsx new file mode 100644 index 0000000..a80f1ad --- /dev/null +++ b/frontend/src/screens/clerical/UnitPage.tsx @@ -0,0 +1,206 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router"; +import { packetsApi } from "../../api/packets"; +import { projectsApi } from "../../api/projects"; +import { useAuth } from "../../auth/useAuth"; +import { Board } from "../../components/Board"; +import { Kpis } from "../../components/Kpis"; +import { useGuard } from "../../components/useGuard"; +import { canEditProjects, canRenumber } from "../../lib/access"; +import { useChanged } from "../../lib/changes"; +import { countCards, dueInfo, IN_SHOP_OR_QC, PROJECT_STATUS_LABEL } from "../../lib/packets"; +import { useLoad } from "../../lib/useLoad"; +import { ProgressBar, ProjectPill } from "./common"; +import { NewPacketSheet, RenumberSheet, UnitSheet } from "./sheets"; + +/** /clerical/units/:unitId: the unit's facts and its packets. */ +export function UnitPage() { + const { unitId } = useParams(); + const id = Number(unitId); + const { me } = useAuth(); + const navigate = useNavigate(); + const guard = useGuard(); + const [unitLoad, reloadUnit] = useLoad((s) => projectsApi.unit(id, s), String(id)); + const [packetsLoad, reloadPackets] = useLoad((s) => packetsApi.list({ unit_id: id }, s), String(id)); + const [sheet, setSheet] = useState<"edit" | "renumber" | "packet" | null>(null); + const [toast, setToast] = useState(null); + const roles = me?.roles ?? []; + useChanged( + () => { + reloadUnit(); + reloadPackets(); + }, + { delayMs: 500 }, + ); + + useEffect(() => { + if (!toast) return; + const t = window.setTimeout(() => setToast(null), 2600); + return () => window.clearTimeout(t); + }, [toast]); + + if (unitLoad.state === "loading") return
Loading
; + if (unitLoad.state === "error") + return ( + <> + + ← All projects + +
{unitLoad.message}
+ + ); + + const u = unitLoad.data; + const t = u.project ?? null; + const list = packetsLoad.state === "ok" ? packetsLoad.data : []; + const c = countCards(list); + const di = dueInfo(u.due_effective, u.status === "closed" || t?.status === "closed"); + const open = u.status !== "closed" && t?.status !== "closed"; + const editable = canEditProjects(roles); + const grp = (st: string) => list.filter((p) => p.status === st); + + return ( + <> + +
+
+
+ Unit{t ? ` of ${t.number}` : ""} · {PROJECT_STATUS_LABEL[u.status]} +
+

+ {u.number} + {u.name ? ` · ${u.name}` : ""} +

+
+
+ Project + {t ? `${t.number} ${t.name ?? ""}` : "-"} +
+
+ Customer + {t?.customer || "-"} +
+
+ Customer PO + {t?.po || "-"} +
+
+ Due + + {di.txt.replace(/^Due /, "")} + {u.due ? "" : " (project)"} + +
+
+ Packets + {c.total} +
+
+ {u.notes ?
{u.notes}
: null} +
+
+ + {editable ? ( + + ) : null} + {canRenumber(roles) ? ( + + ) : null} + {editable && open ? ( + + ) : null} +
+
+ IN_SHOP_OR_QC.includes(p.status)).length, label: "In the shop / Q.C." }, + { n: c.complete, label: "Back for data book" }, + { n: c.filed, label: "Filed" }, + ]} + /> + + {!open ?
This {u.status === "closed" ? "unit" : "project"} is closed. Reopen it to add packets.
: null} + {packetsLoad.state === "error" ?
{packetsLoad.message}
: null} + p.flag), empty: "Nothing flagged." }, + { key: "draft", title: "Ready to release", cards: grp("draft").filter((p) => !p.flag), empty: "Nothing ready to release in this unit." }, + { + key: "prog", + title: "In progress", + cards: list.filter((p) => p.status === "warehouse" || IN_SHOP_OR_QC.includes(p.status)), + empty: "Nothing in progress.", + }, + { key: "back", title: "Back for data book", cards: grp("complete"), empty: "Nothing accepted yet." }, + { key: "filed", title: "Filed", cards: grp("filed"), empty: "Nothing filed yet." }, + ]} + /> + + {sheet === "edit" ? ( + setSheet(null)} + onSaved={() => { + setSheet(null); + setToast("Unit saved"); + reloadUnit(); + }} + /> + ) : null} + {sheet === "renumber" ? ( + setSheet(null)} + onSave={async (lot, reason) => { + const r = await guard.run((g) => projectsApi.renumberUnit(u.id, { lot, reason, ...g }), { sub: u.number }); + if (r === undefined) return; + setSheet(null); + setToast(`Unit renumbered to ${r.number ?? lot}`); + reloadUnit(); + reloadPackets(); + }} + /> + ) : null} + {sheet === "packet" ? ( + setSheet(null)} + onCreated={(p) => { + setSheet(null); + navigate(`/packets/${p.id}`); + }} + /> + ) : null} + {guard.sheet} + {toast ? ( +
+ {toast} +
+ ) : null} + + ); +} diff --git a/frontend/src/screens/clerical/boards.ts b/frontend/src/screens/clerical/boards.ts new file mode 100644 index 0000000..5391188 --- /dev/null +++ b/frontend/src/screens/clerical/boards.ts @@ -0,0 +1,41 @@ +// The Clerical home boards, from one list answer. +import type { ColumnDef } from "../../components/Board"; +import type { PacketCard } from "../../api/types"; +import { IN_SHOP_OR_QC } from "../../lib/packets"; + +export const byTime = (a: string | null, b: string | null) => (a ?? "").localeCompare(b ?? ""); + +/** The boards of the Clerical home, from one list answer. */ +export function clericalBoards(all: PacketCard[]): { columns: ColumnDef[]; flagged: number; warehouse: number; shop: number; toClose: number } { + const drafts = all.filter((p) => p.status === "draft").sort((a, b) => byTime(a.updated_at, b.updated_at)); + const back = all.filter((p) => p.status === "complete" && p.routed_to === "databook"); + const flagged = drafts.filter((p) => p.flag); + const going = drafts.filter((p) => !p.flag); + const toClose = back.filter((p) => !p.vclosed).sort((a, b) => byTime(a.status_at, b.status_at)); + const toBook = back.filter((p) => p.vclosed).sort((a, b) => byTime(a.vclosed?.at ?? null, b.vclosed?.at ?? null)); + const filed = all + .filter((p) => p.status === "filed") + .sort((a, b) => byTime(b.status_at, a.status_at)) + .slice(0, 25); + const columns: ColumnDef[] = [ + { key: "cos", title: "Changes to approve", cards: [], empty: "Arrives in phase 3 with the VISUAL feed." }, + { key: "flagged", title: "Flagged", cards: flagged, empty: "Nothing flagged." }, + { key: "going", title: "Going to the warehouse", cards: going, empty: "Drafts with nothing flagged wait here." }, + { + key: "close", + title: "Ready to close", + cards: toClose, + empty: "Finished packets come back here. Close the job in VISUAL, then mark it closed on the packet.", + }, + { key: "book", title: "Ready for data book", cards: toBook, empty: "Jobs closed in VISUAL wait here for the data book." }, + { key: "filed", title: "Filed", cards: filed, empty: "Nothing filed yet." }, + ]; + return { + columns, + flagged: flagged.length, + warehouse: all.filter((p) => p.status === "warehouse").length, + shop: all.filter((p) => IN_SHOP_OR_QC.includes(p.status)).length, + toClose: toClose.length, + }; +} + diff --git a/frontend/src/screens/clerical/common.tsx b/frontend/src/screens/clerical/common.tsx new file mode 100644 index 0000000..f8c2aab --- /dev/null +++ b/frontend/src/screens/clerical/common.tsx @@ -0,0 +1,72 @@ +import { Link } from "react-router"; +import type { Project, StageCounts } from "../../api/types"; +import { dueInfo, projectPillClass, PROJECT_STATUS_LABEL, stageCounts } from "../../lib/packets"; + +/** The old stageChips(): how many packets sit at each stage. */ +export function StageChips({ c }: { c: StageCounts }) { + const items: Array<[string, number]> = [ + ["Ready to release", c.draft], + ["Warehouse", c.warehouse], + ["Shop", c.shop], + ["Q.C.", c.qc], + ["Accepted", c.complete], + ["Filed", c.filed], + ]; + return ( +
+ {items.map(([l, n]) => ( + + {n} {l} + + ))} +
+ ); +} + +/** The old progressBar(): filed in cyan, accepted in green. */ +export function ProgressBar({ c }: { c: StageCounts }) { + const n = c.total || 0; + const f = n ? (c.filed / n) * 100 : 0; + const a = n ? (c.complete / n) * 100 : 0; + return ( +
+ + +
+ ); +} + +export function ProjectPill({ status }: { status: Project["status"] }) { + return {PROJECT_STATUS_LABEL[status]}; +} + +/** The old projCard(). */ +export function ProjectCard({ p }: { p: Project }) { + const c = stageCounts(p.packets); + const di = dueInfo(p.due, p.status === "closed"); + const units = typeof p.units === "number" ? p.units : p.units.length; + return ( + +
+ {p.number} + +
+
{p.name || "-"}
+
+ {p.customer ?? ""} + {p.po ? ( + <> + {" "} + · PO {p.po} + + ) : null} +
+
{di.txt}
+ + +
+ {units} unit{units === 1 ? "" : "s"} · {c.total} packet{c.total === 1 ? "" : "s"} · {c.filed} filed +
+ + ); +} diff --git a/frontend/src/screens/clerical/sheets.tsx b/frontend/src/screens/clerical/sheets.tsx new file mode 100644 index 0000000..7b65754 --- /dev/null +++ b/frontend/src/screens/clerical/sheets.tsx @@ -0,0 +1,735 @@ +import { useState, type FormEvent } from "react"; +import { packetsApi } from "../../api/packets"; +import { projectsApi, type ProjectFields, type UnitFields } from "../../api/projects"; +import type { PacketCreate, PacketRead, Project, ProjectRef, ProjectStatus, Unit } from "../../api/types"; +import { PickUnit, type UnitPick } from "../../components/PickUnit"; +import { Sheet } from "../../components/Sheet"; +import { useGuard } from "../../components/useGuard"; +import { errorText } from "../../lib/format"; +import { useShopDepartments } from "../../lib/useDepartments"; +import { lotSequence, nextLot, normLot, PROJECT_STATUS_LABEL, RX_LOT, RX_PROJECT } from "../../lib/packets"; + +const STATUSES: ProjectStatus[] = ["open", "hold", "closed"]; + +function Problem({ text }: { text: string | null }) { + if (!text) return null; + return ( +
+ {text} +
+ ); +} + +/** The keys whose value changed, with the old values as the base. */ +function diff(before: T, after: T): { set: Partial; base: Partial } { + const set: Partial = {}; + const base: Partial = {}; + for (const k of Object.keys(after) as Array) { + if ((before[k] ?? null) !== (after[k] ?? null)) { + set[k] = after[k]; + base[k] = before[k]; + } + } + return { set, base }; +} + +const orNull = (s: string) => (s.trim() === "" ? null : s.trim()); + +/* ---------- Project: new or edit ---------- */ + +export function ProjectSheet({ project, onSaved, onCancel }: { project?: Project; onSaved: (p: Project) => void; onCancel: () => void }) { + const guard = useGuard(); + const editing = !!project; + const [number, setNumber] = useState(project?.number ?? ""); + const [name, setName] = useState(project?.name ?? ""); + const [customer, setCustomer] = useState(project?.customer ?? ""); + const [po, setPo] = useState(project?.po ?? ""); + const [due, setDue] = useState(project?.due ?? ""); + const [status, setStatus] = useState(project?.status ?? "open"); + const [notes, setNotes] = useState(project?.notes ?? ""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + const num = number.trim().toUpperCase(); + if (!editing && !RX_PROJECT.test(num)) { + setError("Project number: 1 to 20 letters, numbers or dashes, for example L670."); + return; + } + if (!name.trim()) { + setError("Give the project a name."); + return; + } + setBusy(true); + try { + let saved: Project | undefined; + if (project) { + const before: ProjectFields = { name: project.name, customer: project.customer, po: project.po, due: project.due, status: project.status, notes: project.notes }; + const after: ProjectFields = { name: orNull(name), customer: orNull(customer), po: orNull(po), due: orNull(due), status, notes: orNull(notes) }; + const cas = diff(before, after); + if (Object.keys(cas.set).length === 0) { + onCancel(); + return; + } + saved = await guard.run((g) => projectsApi.patch(project.id, { ...cas, ...g }), { sub: project.number }); + } else { + saved = await guard.run((g) => + projectsApi.create({ number: num, name: name.trim(), customer: customer.trim(), po: po.trim(), due: orNull(due), notes: notes.trim(), ...g }), + ); + } + if (saved) onSaved(saved); + else setBusy(false); + } catch (err) { + setError(errorText(err)); + setBusy(false); + } + }; + + return ( + <> + +
+
+ {editing ? null : ( + + )} + +
+
+ + + + {editing ? ( + + ) : null} +
+