diff --git a/CHANGELOG.md b/CHANGELOG.md index 05bf858..3719247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to this project are recorded here. The format follows [Keep ### Added +- Phase 5: the shop. The Supervisor screen (department chips for people who lead several departments, the change-order headline that cannot be closed, the numbers Ready to assign, At stations, Waiting on review and Station hours vs target, the Change orders on hold table, the station grid with a tile per station showing the job, the welder and stamp, the ticking clock against the target hours and a CHANGE ORDER tag, and the boards Ready to assign, Waiting on review, At Q.C., With warehouse and Routed on) and the Stall screen (station picker, the station hero, My packets and Submitted, waiting on supervisor). +- The shop steps on the packet page, driven by `can` and `blocked`: assign with the station select, a welder picker with search and the stamp filled from the welder's record; pull back to the queue (the clock freezes); Start work, Pause, Resume; Submit to supervisor with a PIN signature and the missing steps listed as links (checklist items, no welds on the drawing, filler metal); the supervisor's review with Data book package ready, Spool ready for inspection, the spool location and a note before Send to Q.C. (signature), Return to station with a return note, and Skip Q.C. with a route to another department (signature); a lead sees the review read-only. Send back to Warehouse and Send back to Clerical with reasons. +- The Weld map tab: the drawing rendered with pdf.js at scale min(2.2, 4200/width), pan, pinch and wheel zoom, FIT, full screen; Mark a weld then tap the joint; welds drawn as a leader line and arrowhead in pink with the id in a white circle; drag the bubble or the arrow; the weld sheet (work order, piece, type from Fillet, Groove, Fill, Seal, Tack, size default 5/16", process from GMAW, FCAW, SMAW, GTAW, SAW, stamp, the BOM items it joins) with Log weld, Save changes and Remove weld; the welds-on-this-sheet list; the weld record (filler, filler lot, preheat, notes) with field-level compare-and-set. Marks from an earlier department show as carried. +- The Heat map tab: the heat table (HEAT NUMBERS, ITEM and HEAT NO., at most 12 rows per group side by side, live heats from the Materials tab, a missing heat as a red dash) placed on a sheet, dragged to move, A- and A+ in 0.15 steps from 0.6 to 2.1, removed; older heat tags in amber shown only when the sheet has no table; a packet with no heat map and no raw material of its own shows the heat map of a sibling packet in the same unit with the same drawing, read-only, with a note. +- The Checklist tab: the department's checklist (weld departments: dwg, heat, wps, fit, pre before welding and vt, stamp, clean, qty after; other departments: dwg, prev, mat before and work, insp, qty when done) on the left and the weld record on the right. Each tick records done, when, who and the stamp. The templates live in the `checklist_templates` settings row (migration 0006). +- VOID stamps on voided drawing pages in the viewer (diagonal VOID, the void text, a red border); a VOID sheet refuses new marks. +- Change orders in the shop: the Changes tab shows CO-n with its items, the required departments and who verified; a supervisor of a pending department verifies with a note and a PIN signature (insert-only). Clerical's approval of a feed change order applies the staged change: new pages added, old pages voided, rev and drawing set, work orders remapped, the BOM merged keeping heat, N/A, issued, location, pulled and the short on matching lines (vkey, else item and part), and the heat table copied to the new sheet. Required departments are the ones the packet touched, without Clerical; a minor change order needs none. The hold texts read "CO-2: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it." and "Change order CO-2 must be verified first by: 03 Weld Parts." +- The station clock is kept on the server (start, pause, resume, folded on pull back and submit); the read model carries the live elapsed time and `server_now`, and no client value is ever stored. VISUAL labor ticket hours from the feed cache are summed per work order and shown beside the clock. +- Presence: the packet page shows who else has it open ("Also here: ..."), from the event stream. +- New endpoints: `GET /api/shop/board`, `GET /api/shop/stations`, `GET /api/shop/stall`, `GET /api/departments/{code}/people`. Assign checks that the welder belongs to the department and that a limited worker's station is allowed. Welds and heat tags carry a label position (`bfx`, `bfy`); the heat table placement carries `carried_from`. +- Packet history texts as the old build: "Sent to Stall 3", "Pulled back from Stall 3", "Work started", "Submitted to supervisor", "Sent to Q.C.", "Returned to Stall 3", "Routed to 09 Hydro", "Weld W4 marked", "Heat table placed on drawing", "Change order CO-2 verified by Weld Parts". +- Tests: every shop step as every role and status against a policy oracle, the preconditions, clock arithmetic, server-assigned weld and heat tag numbers under concurrent inserts, carried marks on routing, change-order approval applying the staged change and the BOM merge rules, verification per department and the hold; frontend tests for the canvas geometry, the heat table layout, the clock, the checklist, the weld sheet, the shop screens and the role switcher; Playwright scenarios for assign, start, submit, review and send to Q.C., the heat table, the change-order banner and verify, custody, two users at once, and the skip-Q.C. route. +- docs/SCHEMA.md, POLICY.md, SECURITY.md and FEED.md updated. + - Phase 4: the Warehouse. The Warehouse home with the numbers (Waiting to pull, Being pulled, On hold, Ready for production, Open shorts, Released last 24 h), the Open shorts table with Receive, the find bar, and the task board: Waiting to pull, Being pulled, On hold, Pulled, ready for production, and Released to shop (3 days) built from the release transitions. Cards are grouped by department, oldest first by the day they were sent then VISUAL's release date, and move by button (Start pulling, Hold, Mark pulled with a warning when lines remain, Resume pulling, Back to pulling, Release…) or by dragging; a drop on the released column is refused. Hold asks for one of five reasons and a note ("Other" needs the note). Every role sees the real stage on a warehouse packet: the pill reads WH · waiting, being pulled, on hold or pulled, with who, how long and the hold reason under it. - The pull table on the Materials tab for the warehouse: Pulled (the first tick starts the pulling stage), issued quantity, location, heat number typed or scanned with Enter moving to the next line, Short (record, edit, Not short after all) with the seven reasons, quantity, PO or requisition, ETA and note, receive with quantity, heat and note as an append-only receipt, N/A for parts with no traceability, and an MTR (PDF, PNG or JPG) per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays disabled with the server's reasons until every line not N/A is pulled or on an open short and every pulled line has a heat or is N/A or a sub-assembly. - `GET /api/warehouse/board` (numbers, the five columns, the open shorts). Board cards carry the warehouse stage with who and when, the sent date, VISUAL's release date and the pull counts. diff --git a/README.md b/README.md index 0d151d9..1518ad1 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo ## Status -**Rebuild in progress. Phase 4 done: the Warehouse screen, the pull table, shorts and MTRs, and the hardened upload pipeline.** +**Rebuild in progress. Phase 5 done: the Supervisor and Stall screens, the weld map, the heat table, the station clock and change-order verification.** -People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. The worker reads released work orders from VISUAL on a schedule, makes and updates drafts, opens change orders on packets already in the shop, looks drawings up in M-Files and attaches them at the rev VISUAL calls. Clerical sees it all on the From VISUAL panel and in the Flagged and Changes to approve columns; Admin sets the feed up on the Feed tab. Warehouse works its task board (waiting, being pulled, on hold, pulled, released), pulls parts line by line with heat numbers, shorts, receipts and MTRs, and releases to the supervisor once every line is pulled or short and every pulled line has a heat. Every packet write goes through the server policy. The shop and Q.C. screens are still placeholders; their API exists. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:. +People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. The worker reads released work orders from VISUAL on a schedule, makes and updates drafts, opens change orders on packets already in the shop, looks drawings up in M-Files and attaches them at the rev VISUAL calls. Clerical sees it all on the From VISUAL panel and in the Flagged and Changes to approve columns; Admin sets the feed up on the Feed tab. Warehouse works its task board (waiting, being pulled, on hold, pulled, released), pulls parts line by line with heat numbers, shorts, receipts and MTRs, and releases to the supervisor once every line is pulled or short and every pulled line has a heat. A supervisor assigns the packet to a station and a welder, the welder starts the clock, marks welds on the drawing, places the heat table, finishes the checklist and submits with a PIN signature, and the supervisor sends it to Q.C. or routes it to another department with the marks carried. Department supervisors verify change orders with a signature. Every packet write goes through the server policy. The Q.C. screen is still a placeholder; its 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? | |---|---| @@ -18,7 +18,8 @@ People can sign in, set up projects and units, make hand drafts, attach a drawin | Schema, policy engine, packet API, projects and units, assets, Clerical screen, packet page | Yes (phase 2, verified against PostgreSQL and the Samba DC in the CI stack on docker-1) | | VISUAL feed, M-Files drawings, change orders from the feed, From VISUAL panel, Admin Feed tab and resource map | Yes (phase 3, run once against VISUAL DEV and the vault from a developer machine; fixture-driven in CI) | | Warehouse screen, pull table, shorts and receipts, MTRs, release gate, upload kinds, optional ClamAV, storage alert | Yes (phase 4, verified in the CI stack on docker-1) | -| Shop, Q.C., close and file, PDFs | API only (phase 2); screens in phases 5 to 7 | +| Supervisor and Stall screens, weld map, heat table, station clock, change-order verification | Yes (phase 5, verified in the CI stack on docker-1) | +| Q.C., close and file, PDFs | API only (phase 2); screens in phases 6 and 7 | | Project Management, Admin, notifications, backups | No (phases 8 and 9) | | Regression bench and go-live | No (phase 10) | diff --git a/docs/FEED.md b/docs/FEED.md index 4bf39ee..a6f4b07 100644 --- a/docs/FEED.md +++ b/docs/FEED.md @@ -414,11 +414,23 @@ For CI and local runs the worker can read files instead of VISUAL and M-Files: Real job data never enters git or CI. Recordings from VISUAL DEV stay local, in `tests/fixtures/local/` and `recordings/`. +## Ticket hours beside the station clock + +The packet read model has a `labor` field from `visual_labor`. It joins the packet's work orders on (`wo_base`, `lot`, `split`, `sub`, `seq`) and sums per work order: + +- `hours` and `tickets`: the totals over the packet's work orders. +- `per_wo`: one entry per work order leg, with `wo_base`, `lot`, `split`, `sub`, `seq`, `hours`, `tickets` and `last_ticket`. +- `observed_at`: when the worker last read the tickets. + +`labor` is null for a hand packet, or when no `visual_labor` row matches. The packet page shows the sum beside the station clock, as `VISUAL tickets 3.2 h`. + +The station timer stays the clock of record (open question 1, default kept). The ticket hours are shown for comparison only. Nothing is computed from them, and they never change the clock. The screen reads them from PostgreSQL, never from VISUAL. + ## What the screens read Screens read PostgreSQL only, never VISUAL or M-Files per request: -- Packets, flags, work order status, closed and cancelled state, ticket hours and the chosen order come from the packet tables and `packet_visual_state`. +- Packets, flags, work order status, closed and cancelled state, ticket hours and the chosen order come from the packet tables and `packet_visual_state`. The ticket hours beside the station clock come from `visual_labor` (see "Ticket hours beside the station clock"). - The From VISUAL panel reads `/api/feed/overview`: pause state, next run, the heartbeat, the last ten feed and drawing runs, the ignored work orders, unmapped resources and projects without names. - Admin reads the cache tables (`/api/feed/batches`, `/api/feed/changes`) for "what VISUAL changed". - The project page reads the cached orders from `visual_order_links` and `visual_orders`. diff --git a/docs/PLAN.md b/docs/PLAN.md index 9cecd92..d4253da 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -215,7 +215,7 @@ Conventions: identity bigints except users (uuid = AD objectGUID); Postgres enum Need an answer before the named phase. Defaults apply if none arrives in time. -1. **Station timer** (Seth, before phase 5): keep, cross-check against labor tickets, or drop. Default: keep the timer, show ticket hours beside it. +1. **Station timer** (Seth, before phase 5): keep, cross-check against labor tickets, or drop. Default: keep the timer, show ticket hours beside it. Phase 5 (2026-10-16) applied the default: the station timer is the clock of record and the VISUAL ticket hours are shown beside it. 2. **PDFs on the server** (confirm before phase 7): reportlab + pypdf + pypdfium2. Default: yes. 3. **Frontend stack** (confirm before phase 1): TypeScript + React + Vite. Default: yes. Plain JavaScript would not be faster for parity: the old 7,000 lines are screen code that must be rewritten either way, and typed API models cut rework. 4. **PIN defaults** (confirm): 6 to 12 digits, five attempts, 10-hour shop session, device cookie. Genuinely open (Seth and Q.C., before phase 6): whether Q.C. accept and filing require the domain password even with a PIN set. Default: PIN is enough. IT: device certificate later. @@ -245,6 +245,12 @@ From phase 4 (2026-10-16), for Seth to veto: 22. Upload kinds and limits: default an MTR is PDF, PNG or JPG up to 25 MB, a hand BOM is XLSX or CSV up to 5 MB. ClamAV is optional and off unless CLAMAV_HOST is set; when set, an unreachable scanner refuses uploads (fail closed). 23. The quota alert: default a `notification_events` row (phase 9 builds the delivery on this table) at 85 percent of the assets volume. +From phase 5 (2026-10-16), for Seth to veto: + +24. Carried marks: when a packet is routed to another department, the welds, heat tags and heat table stay on the packet. The read model computes a `carried` flag. Nothing is copied. Default: computed, not copied. +25. The old build's manual "copy marks to the new sheet" button on a change order is not ported. On a page replacement only the heat table placement is copied to the new page. Welds and heat tags stay on the VOID page. Default: not ported in phase 5; a follow-up. +26. Checklist templates live in the `checklist_templates` settings row, with the old lists as defaults. Default: no screen edits them in phase 5; Admin editing comes in phase 9. + ## Verification - Every phase: `pytest` server suite, `npm test` and `npm run build` for the frontend, `docker compose -f deploy/compose.ci.yml up` with the Samba DC and the Playwright scenarios for that phase, `packetstation doctor` inside the image. diff --git a/docs/POLICY.md b/docs/POLICY.md index 8ceb0aa..9430968 100644 --- a/docs/POLICY.md +++ b/docs/POLICY.md @@ -147,7 +147,7 @@ Each precondition is a named predicate over the packet state and the request inp | 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. | +| welds_recorded | at least one weld in the packet's current department, 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). | @@ -297,6 +297,142 @@ The `action` column of `packet_log` for each warehouse write. `{item}` is the BO When a Pulled tick starts pulling by itself, there is no separate stage row in the log: the line's own row gets ` · started pulling` or ` · pulling resumed` on its note. Every write above also writes its audit row through the write pipeline, with `detail.item`, `wh_from` and `wh_to` as before, and `detail.qty` on short writes. +## Shop (phase 5) + +### Who takes each step + +| Action | Who | From | Notes | +|---|---|---|---| +| assign | Lead or supervisor of the packet's department (DEPT_LEAD) | ready | Station and welder checked at assign time (below) | +| unassign | DEPT_LEAD | assigned | Clears station and welder, folds the clock | +| pull_back | DEPT_LEAD | in_progress | Clears station and welder, folds the clock | +| start | Lead or supervisor of the department, or the assigned welder within their station limits (WORKS_ON) | assigned | Starts the clock | +| submit | WORKS_ON | assigned, in_progress | Stops the clock. Signature "Submit work to the supervisor" | +| return_to_station | Supervisor of the department (DEPT_SUP) | review | Reason | +| send_to_qc | DEPT_SUP | review | Signature "Send to Q.C.". Opens the inspection | +| route_from_review | DEPT_SUP | review | Signature "Route to another department". Closes the stage and opens one in the target department | +| send_back_to_warehouse | DEPT_LEAD | ready | Reason | +| return_to_clerical | DEPT_LEAD | ready, only when wh_skipped | Reason | + +A worker who is not the assigned welder cannot start or submit. Neither can the assigned welder when their station limits leave out the packet's station. Every step sends `expect: {status, dept}`, so a stale screen gets 409 `stale_step` "Someone else already took this step." + +### Assign checks + +ASSIGN checks the station and the welder on the server, at assign time: + +| Check | Refusal | +|---|---| +| The station is an active station of the packet's department | 422 `invalid_station` | +| The welder is an active user with a shop role (worker, lead or supervisor) in the packet's department | 422 `invalid_welder` "Pick a welder in this department." | +| A worker with station limits (no lead or supervisor role in the department) is limited to the chosen station | 422 `station_limit` "{name} is limited to {station names}." | +| A `stamp` in the body matches the stamp pattern | 422 `invalid_stamp` | + +The stamp in the body overrides the user's `welder_stamp` for this assignment. Without it, the user's stamp is used. The packet and the stage row both get the station, the welder and the stamp. `GET /api/departments/{code}/people` gives the assign sheet its welder list, with each person's level and station limits. + +### The lead's review + +A lead holds a packet in review (see "Custody"), but return_to_station, send_to_qc and route_from_review are the supervisor's. The server refuses them to a lead with 403 `forbidden`. The screen shows the lead the review read-only: "A supervisor for 03 Weld Parts reviews it and sends it on." + +### Preconditions on the shop steps + +| Step | Precondition | Passes when | Message | +|---|---|---|---| +| assign | welder_set (input) | the request gives station_id and welder_id | Pick a station and a welder. | +| submit | checklist_complete | every key of the department's checklist is done | Finish the checklist first. | +| submit | welds_recorded | at least one live weld whose `dept_code` is the packet's current department, in a weld department | Record at least one weld first. | +| submit | filler_set | filler metal entered on the open stage, in a weld department | Enter the filler metal first. | +| send_to_qc | qc_book_spool_location (input) | book and spool confirmed and a location given | Confirm the book and the spool, and give the location. | +| return_to_station, send_back_to_warehouse, return_to_clerical | reason_given (input) | a reason of at least 8 characters after cleaning | Give a reason of at least 8 characters. | +| route_from_review | target_dept_given (input) | a shop department different from the packet's | Choose a department. / Choose a different department. | + +welds_recorded counts only the current department's welds. After a route, the new department needs a weld of its own before it can submit. The welds from earlier departments are carried marks (see [SCHEMA.md](SCHEMA.md), "Carried marks"). + +### Change-order hold + +An open change order holds these steps: WH_RELEASE, SUBMIT, SEND_TO_QC, ROUTE_FROM_REVIEW, QC_ACCEPT, QC_ACCEPT_AND_ROUTE and FILE. It does not hold ASSIGN, START, the clock or the edits, so the shop keeps working while the departments verify. A change order is open when it is approved with departments still to verify, or pending with hold on (`v_open_cos`). + +The hold text (the `co_hold` refusal and the `blocked` reason): + +| Change order | Text | +|---|---| +| Pending, with hold | `CO-2: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it.` | +| Approved, departments pending | `Change order CO-2 must be verified first by: 03 Weld Parts, 09 Hydro.` | + +With several open change orders, the texts are joined by a space, one sentence each. The id is `CO-` everywhere. + +### Change-order verification + +CO_VERIFY is the supervisor of the department named in the request: a shop Supervisor for a shop code, the WH or Q.C. Supervisor for WH or QC. A lead cannot verify (403 `forbidden`). Clerical never verifies. Each verification needs a signature ("Verify a change order for the department") and is an insert into `co_verifications`. A second one for the same department is 409 `already_verified`. A department the change order does not need is 422 `invalid_dept`. A verification cannot be withdrawn. + +The required departments on approval are the departments the change touches, without Clerical and PM. A minor change order needs none. + +### The clock + +CLOCK is the holder's in the shop statuses. The ops are `start`, `pause` and `resume`. An op in the wrong state is 409 `clock_state`. The server keeps the time with the database clock. No request sets the elapsed time. START starts the clock when it is not running. SUBMIT stops it. UNASSIGN and PULL_BACK fold the running time in, so the clock freezes. See [SCHEMA.md](SCHEMA.md), "The clock". + +### Marks on the drawing + +Welds (EDIT_WELDS), heat tags (EDIT_HEATS) and the heat table (EDIT_HEAT_TABLE) follow the rule table: the holder in the shop statuses, and for heat tags and the heat table also the warehouse while it holds the packet. The server hands out `W` and `H` from the packet counters under the packet row lock. + +A VOID sheet refuses marks. A new weld or heat tag on a voided page, or a move of one already there, is 422 `void_page` "This sheet is VOID. Mark the current sheet." The marks on a void page stay and are shown, but cannot be edited. + +### Heat map borrowing + +A packet borrows a sibling's heat map when all of these hold (the old heatSrc): + +- It has no heat map of its own: no heat table placement and no heat tag. +- It has no raw material of its own: every BOM line is N/A or a sub-assembly, or the BOM is empty. +- It has a drawing. + +The sibling is a live packet in the same unit, with a different id and the same drawing (compared upper-case with white space removed), that has a heat map. The same rev is preferred, then the oldest. The read model gives it as `heat_src`. It is read-only: nothing on it can be written through the borrowing packet. The screen says "Heat map from on this unit (same drawing). Record heats there." + +### Shop screens + +- `GET /api/shop/board` covers the shop departments where the person is lead or supervisor. Admin sees every active shop department. A department outside that scope is 403 `forbidden`. A person who leads no department gets 403 `forbidden` "You do not lead a department." The change order list also shows open change orders anywhere that wait on one of the person's departments. +- `GET /api/shop/stations` lists the stations the person may pick for the Stall screen: every station of their shop departments at any level, cut to their station limits for a worker. Admin gets every station. +- `GET /api/shop/stall?station_id=` is 403 for a station not in that list. Admin passes. + +### Presence + +The packet page shows who else has the packet open. Presence comes over SSE and carries the user id, display name, screen and packet id only. + +### History texts + +The `action` and `note` columns of `packet_log` for each shop write. A department is shown as code and name (`03 Weld Parts`), except in the verify text, which uses the name only. The station name is as stored (`Stall 3`). + +| Write | action | note | +|---|---|---| +| assign | `Sent to Stall 3` | `Andy Welder [75]` (the stamp in brackets when set) | +| unassign | `Taken off Stall 3` | | +| pull_back | `Pulled back from Stall 3` | | +| start | `Work started` | `Stall 3` | +| submit | `Submitted to supervisor` | `4 welds · 1.5 h` | +| return_to_station | `Returned to Stall 3` | the reason | +| send_to_qc | `Sent to Q.C.` | `Spool at Bay 2 · note` | +| route_from_review | `Routed to 09 Hydro` | `Supervisor, Q.C. skipped · from 03 Weld Parts` | +| send_back_to_warehouse | `Sent back to the warehouse` | the reason | +| return_to_clerical | `Sent back to Clerical` | the reason | +| Weld added | `Weld W4 marked` | `Fillet 5/16" · items 1 + 2 · L662001 #1` | +| Weld edited | `Weld W4 changed` | the changed fields, `type Groove · items 1 + 3` | +| Weld moved (position only) | `Weld W4 moved` | | +| Weld removed | `Weld W4 removed` | the reason | +| Heat tag | `Heat tag H2 placed`, `Heat tag H2 changed`, `Heat tag H2 moved`, `Heat tag H2 removed` | `item 3` | +| Heat table placed | `Heat table placed on drawing` | the page's dwg, else `sheet 2` | +| Heat table moved | `Heat table moved` | the same | +| Heat table resized | `Heat table resized` | `A+` or `A-` and the new size, `A+ 1.15` | +| Heat table removed | `Heat table removed from drawing` | the same | +| Heat table copied on a change order | `Heat table copied to new sheet` | `from the VOID copy, check its position on the new revision` | +| Checklist | `Checked: `, `Unchecked: ` | | +| Stage fields | `Weld record changed` | the changed fields, `filler ER70S-6 · filler lot 1234` | +| Clock | `Clock started`, `Paused`, `Resumed` | | +| Change order issued | `Change order CO-2 issued` | | +| Change order approved | `Change order CO-2 approved` | | +| Change order rejected | `Change order CO-2 rejected` | the reason | +| Change order hold | `Change order CO-2 holds the packet`, `Change order CO-2 no longer holds the packet` | | +| Change order verified | `Change order CO-2 verified by Weld Parts` | `note · still waiting on Hydro`, or `note · change order fully verified` | + +The submit hours are the stage's `clock_elapsed_ms` after the stop, with one decimal. + ## Worker writes (phase 3) The worker has no session. It writes through the same policy as a person, as the system principal `Principal(user_id=None, system="visual-feed")`. Each write locks the packet row (`FOR UPDATE SKIP LOCKED`; a row locked by someone else is left for the next run), builds the packet state, runs `policy.check()`, makes its change, bumps `version`, and writes a `packet_log` row (by nobody, by name "VISUAL feed", role `system`), an audit row (`packet.`, role `system`) and a change event. See [FEED.md](FEED.md) for when each write happens. @@ -352,7 +488,7 @@ The old policy was looser than the old screens in places. The rebuild follows th | 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 | +| Change-order verifications were stored in the packet document, with no rule against changing them later. | Verifications need a signature, are insert-only and cannot be withdrawn (open question 18). | A signed verification is a record. | Yes | | The old policy did not look at the BOM when the warehouse released a packet. | WH_RELEASE needs every BOM line not marked n/a to be pulled or to have an open short. | A packet should reach the shop with its material, or with the shorts on record. | Yes | | The old screen asked for a heat number on every pulled line before release, but the policy did not check it. | WH_RELEASE also needs a heat on every pulled line that issued pieces, unless the line is N/A or a sub-assembly (`heats_recorded`), checked on the server. | Traceability is the point of the pull. The screen alone could be skipped. | Yes | | Hold reasons and short reasons were any text the screen sent. | Both are closed sets checked on the server. "Other" on a hold needs a note. | The board and the shorts table group and report on the reason, so it must be one of the list. | Yes | @@ -364,3 +500,7 @@ The old policy was looser than the old screens in places. The rebuild follows th | Approving a different M-Files rev was open to a Clerical lead. | rev_approve needs Clerical Supervisor, with a reason and a signature. | There is no Clerical lead AD group, and the approval overrides VISUAL. | Yes | | The Sync on a Clerical PC skipped removed packets ("removed here, not re-made"), but only as the browser saw them. | A removed packet's key is never recreated, whatever VISUAL says. The match runs on the server across live and removed rows, in the feed's transaction. | A removal is a decision. The feed does not undo it. | No | | The Sync saved through the same packet document that held the shop's work, so nothing but the code kept it off the shop's fields. | The feed never writes shop-side tables. Packets past draft only get a change order, observed VISUAL state and drawing flags. | The shop's record is the shop's. | No | +| The assign sheet only offered welders of the department and filtered the stations by the welder's limits. The policy checked neither. | ASSIGN checks on the server that the welder has a shop role in the packet's department, and that a limited worker's station is one of their stations. | The picker alone could be skipped. | Yes | +| The old screen asked for a weld of the current department before submit, but the policy did not check welds. | welds_recorded counts the welds of the packet's current department, on the server. After a route the new department marks its own. | Each department records its own welds. | Yes | +| A mark could be placed on a VOID sheet. | A VOID sheet refuses new marks and moves (422 `void_page`). The marks already there stay. | Marks belong on the current revision. | Yes | +| The station clock ran in the browser, and the elapsed time was saved from the browser. | The server keeps the clock with the database time. No request sets the elapsed time. | The clock is a record. A browser clock can be wrong or changed. | No | diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 6acab59..69a3c6d 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -1,6 +1,6 @@ # Packet Station schema -Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 9 adds the notification rules and delivery. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema". +Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 9 adds the notification rules and delivery. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema". ## Conventions @@ -95,10 +95,10 @@ What the warehouse columns mean in practice (phase 4): - `bom_shorts`: one open short per line at most. Opening a short sets the line's `issued` to `qty` minus the short quantity and ticks it pulled. `qty` is what was short in total, `qty_open` what is still missing. `reason` is one of the closed short reasons, `ref` the PO or requisition, `eta` the expected date. Editing an open short keeps what was already received in `qty`. "Not short after all" closes it (`closed_at`) and sets `issued` to the line's `qty`. While a short is open, the line's heat, issued and location change only through the short. - `short_receipts`: one row per delivery, never changed. Each receipt lowers `qty_open`, adds its pieces to the line's `issued` and its heat to the line's `heat`. The receipt that brings `qty_open` to 0 closes the short. - `packet_wh`: `reason` is one of the closed hold reasons when the stage is `hold`, and `note` is up to 200 characters. -- `packet_stages`: one row per visit to a shop department. `dept_code`, `station_id`, `welder_id`, `welder_stamp`, `checklist` jsonb (key to `{done, at, by, stamp}`, written per key with `jsonb_set`), `filler`, `filler_lot`, `preheat`, `notes`, the clock (`clock_elapsed_ms`, `clock_running_since`, `clock_started_at`), `qc_snapshot`, `opened_at`, `closed_at`, `row_version`. At most one open stage per packet. -- `welds`: `weld_no` (from `next_weld_no`), `page`, `fx` and `fy` (fractions of the page), `wo_base`, `piece`, `items` int[], `type`, `size`, `process`, `stamp`, `welder_id`, `dept_code`, `stage_id`, `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `weld_no`) among live rows. -- `heat_tags`: `tag_no` (from `next_heat_no`), `page`, `item`, `fx`, `fy`, `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `tag_no`) among live rows. -- `heat_table_placements`: `page`, `x`, `y`, `s`, `by`, `at`. Unique on (`packet_id`, `page`), written as an upsert. +- `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`; only the server writes them, see "The clock"), `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), `bfx` and `bfy` (0006), `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`, `bfx` and `bfy` (0006), `carried_from`, `at`, `by`, `row_version`, soft delete. Unique on (`packet_id`, `tag_no`) among live rows. +- `heat_table_placements`: `page`, `x`, `y`, `s`, `carried_from` (0006), `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. @@ -109,6 +109,87 @@ What the warehouse columns mean in practice (phase 4): Every who column references `users (id)`. Every `signature_id` references `signatures (id)`. +## Shop (phase 5, migration 0006) + +### Column additions + +- `welds`: `bfx` and `bfy` (double precision, null). The label (bubble) position as fractions of the page. `fx` and `fy` stay where the arrow points. +- `heat_tags`: `bfx` and `bfy`, the same. +- `heat_table_placements`: `carried_from` (int, null). The packet page number the table was copied from on a change order page replacement. + +A null `bfx` means the label has not been moved. The client then draws it at the old default offset: `bfx = clamp01(fx + (fx > 0.85 ? -0.05 : 0.05))` and `bfy = clamp01(fy + (fy < 0.12 ? 0.07 : -0.07))`, where clamp01 keeps a value between 0.01 and 0.99. + +The API checks the values the columns have no CHECK for: + +| Value | Rule | Refusal | +|---|---|---| +| `fx`, `fy`, `bfx`, `bfy` | 0 to 1, or null for `bfx` and `bfy` | 422 | +| `welds.type` | Fillet, Groove, Fill, Seal or Tack | 422 `invalid_weld` | +| `welds.process` | GMAW, FCAW, SMAW, GTAW or SAW | 422 `invalid_weld` | +| `welds.size` | `5/16"` on insert when missing | | +| `welds.piece` | 1 or more | 422 | +| `welds.wo_base` | one of the packet's work order bases | 422 `invalid_wo` | +| `heat_table_placements.x`, `y` | 0 to 0.97 | 422 `invalid_heat_table` | +| `heat_table_placements.s` | 0.6 to 2.1, in steps of 0.15 from the screen | 422 `invalid_heat_table` | +| `page` of a heat table placement | a live or voided drawing page of the packet | 422 `invalid_page` | +| `page` of a new or moved weld or heat tag | not a voided page | 422 `void_page` | + +### Checklist templates + +The `settings` row `checklist_templates` holds the shop checklists. Migration 0006 inserts the defaults with `ON CONFLICT DO NOTHING`, so a row already there is kept. The value: + +```json +{ + "weld": [{"group": "Before welding", "key": "dwg", "text": "Drawing {drawing} Rev {rev} matches the packet"}, ...], + "plain": [{"group": "Before starting", "key": "dwg", "text": "..."}, ...] +} +``` + +`weld` is the list for a weld department (`departments.weld` true), `plain` for every other shop department. Each entry has a `group`, a `key` and a `text`. The text may hold the placeholders `{drawing}`, `{rev}` and `{wps}`, filled from the packet. + +| List | Group | Keys | +|---|---|---| +| weld | Before welding | `dwg`, `heat`, `wps`, `fit`, `pre` | +| weld | After welding | `vt`, `stamp`, `clean`, `qty` | +| plain | Before starting | `dwg`, `prev`, `mat` | +| plain | When done | `work`, `insp`, `qty` | + +When the row is missing or malformed, the defaults in `domain/checklists.py` apply and a warning is logged. The read model and the checklist write both use the loaded templates. No screen edits the row in phase 5. Phase 9 (Admin) adds that. + +`packet_stages.checklist` maps each key to `{done, at, by, stamp}`. `by` is the user id. `stamp` is the user's `welder_stamp`, else the packet's. A key not in the department's list is 422. + +### Carried marks + +Welds, heat tags and heat table placements belong to the packet, not to the stage. Routing a packet to another department copies nothing. The read model computes a `carried` flag instead: + +- A weld is carried when `stage_id` is set and is not the open stage's id. It was logged in an earlier department's stage. +- A heat tag is carried when its `at` is before the open stage's `opened_at`. The warehouse or an earlier department placed it. + +The `carried_from` columns on `welds` and `heat_tags` (from 0003) are not written by routing. + +There is one copy, on a change order page replacement. When a feed change order is approved and a drawing page is voided, each heat table placement on the voided page is copied to its new page with `carried_from` set to the old page number. The log says `Heat table copied to new sheet` with the note `from the VOID copy, check its position on the new revision`. Welds and heat tags stay on the void page. + +### The clock + +`packet_stages` has three clock columns (from 0003): + +- `clock_elapsed_ms` (bigint, default 0): the stored total. +- `clock_running_since` (timestamptz): when the clock last started. Null while it is stopped. +- `clock_started_at` (timestamptz): the first start of the stage. + +Only the server writes them, with the database `now()`. No request field sets them, and a value from the client is never used. + +| Event | Write | +|---|---| +| START, or clock `start` | `clock_running_since = now()` when it is not running; `clock_started_at = coalesce(clock_started_at, now())` | +| clock `pause` | `clock_elapsed_ms += now() - clock_running_since`; `clock_running_since = null` | +| clock `resume` | `clock_running_since = now()` | +| PULL_BACK, UNASSIGN, SUBMIT | fold: `clock_elapsed_ms += now() - clock_running_since` when running; `clock_running_since = null` | + +A clock op in the wrong state is 409 `clock_state`. + +The read model gives `server_now` (the database `now()`) and `stage.clock`: `{elapsed_ms, stored_ms, running, running_since, started_at}`. `stored_ms` is the column. `elapsed_ms` is live: the stored total plus `now() - clock_running_since` while running. Each closed stage has the same object, with `running` false. The client adds the time since the fetch to `elapsed_ms` for the ticking display only. + ## Change orders (phases 2 and 3) - `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. @@ -149,7 +230,7 @@ The app role is refused by privilege first. The migrate role owns the tables, so ## Audit, settings, jobs (phases 1 and 3) - `audit_events` (migration 0001): append-only. `at`, `user_id`, `session_id`, `role_snapshot` (`system` for the worker), `action`, `subject_type`, `subject_id`, `packet_id`, `detail` jsonb, `it_reason`, `ip`. A hash chain: `row_hash` is sha256 over `prev_hash` and the canonical JSON of the row, and `prev_hash` is the row before it. BRIN index on `at`. -- `settings` (migration 0001): `key` (text PK), `value` jsonb, `version`, `updated_at`, `updated_by`. Phase 3 adds the row `feed`, the feed settings as one JSON object (see [FEED.md](FEED.md), "Settings"). A missing key takes its default. A save bumps `version` and sends `ps_notify('ps_settings', ...)` so the worker reschedules. +- `settings` (migration 0001): `key` (text PK), `value` jsonb, `version`, `updated_at`, `updated_by`. Phase 3 adds the row `feed`, the feed settings as one JSON object (see [FEED.md](FEED.md), "Settings"). Phase 5 adds the row `checklist_templates` (see "Checklist templates"). A missing key takes its default. A save bumps `version` and sends `ps_notify('ps_settings', ...)` so the worker reschedules. Phase 3, migration 0004: diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 6a7aaf8..29d1275 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 4. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Phase 3 adds the worker controls (read-only VISUAL and M-Files reads, stack guard, advisory locks, redacted errors, audited system writes). No requirement ID closes in phase 3. Phase 4 completes the upload pipeline (a kind per upload with its own types and limit, XLSX and CSV by content, the optional ClamAV scan, the assets volume quota alert) and adds the warehouse rules (the heat gate on release, closed hold and short reasons, receive by custody). Phase 4 closes D5. Every other control names the phase that builds it. +Status: phase 5. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Phase 3 adds the worker controls (read-only VISUAL and M-Files reads, stack guard, advisory locks, redacted errors, audited system writes). No requirement ID closes in phase 3. Phase 4 completes the upload pipeline (a kind per upload with its own types and limit, XLSX and CSV by content, the optional ClamAV scan, the assets volume quota alert) and adds the warehouse rules (the heat gate on release, closed hold and short reasons, receive by custody). Phase 4 closes D5. Phase 5 adds the shop rules (the server as the clock of record, server-assigned mark numbers under the packet row lock, insert-only change-order verifications with a signature, VOID sheets never edited). Every other control names the phase that builds it. Contents: @@ -99,6 +99,16 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro - Short receipts go to the warehouse in any status but filed, or to the holder of the packet through the BOM edit rule. Nobody else. - An MTR on a line must be a PDF, PNG or JPEG asset. +### Shop rules (phase 5) + +- The server is the clock of record. The station clock is kept in `packet_stages` with the database time. Only the server writes the clock columns. No request field sets the elapsed time. +- Weld and heat tag numbers (`W`, `H`) are assigned by the server from the packet counters, under the packet row lock. Two people marking at once never get the same number. +- ASSIGN checks on the server that the welder belongs to the department and that a limited worker's station is one of theirs. +- A change-order verification needs a signature and is insert-only. A second one for the same department is refused. It cannot be withdrawn. +- VOID sheets are never edited. A voided page is kept, and new marks or moves on it are refused (422 `void_page`). +- A borrowed heat map is read-only. Nothing on the sibling packet can be written through the borrowing packet. +- Presence over SSE carries display names, never other personal data. Each entry is the user id, display name, screen and packet id. + ### Worker (phase 3) - The worker publishes no port and joins no proxy network. It reaches out to VISUAL and M-Files only; nothing reaches in. Admin and Clerical reach it only through rows in PostgreSQL (`job_requests`, the `feed` settings row) and NOTIFY. @@ -171,7 +181,7 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved | S3 | Closed, phase 9 (CI scanning) | | S4 | Deferred to IT (pentest before go-live) | | 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 | +| S6, S7, S8 | Closed, phase 7. PLAN.md lists S8 under phase 5, but the print stamps come with the PDFs in phase 7, so S8 closes there. | | O1, O2 | Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's | | O3, O4 | Closed, phase 10 | | O5 | Superseded (no migration; M: retired read-only) | diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f581f1a..d76792b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -56,7 +56,7 @@ describe("sign-in", () => { fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret-pass" } }); fireEvent.click(screen.getByRole("button", { name: "Sign in" })); - expect(await screen.findByRole("heading", { name: "Supervisor" })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Dispatch, review, route" })).toBeInTheDocument(); expect(callsTo(fetch, "POST", "/api/auth/signin/password")).toEqual([{ login: "sup.wes", password: "secret-pass" }]); expect(screen.getByText("Clara Clerk")).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Account" })).toBeInTheDocument(); @@ -247,8 +247,7 @@ describe("role switcher", () => { expect(within(nav).getByRole("button", { name: "Clerical" })).toHaveAttribute("aria-pressed", "true"); fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" })); - expect(await screen.findByRole("heading", { name: "Weld Stall" })).toBeInTheDocument(); - expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true"); + await waitFor(() => expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true")); }); it("is hidden for a user with one screen", async () => { diff --git a/frontend/src/api/packets.ts b/frontend/src/api/packets.ts index 2e67457..64c70b1 100644 --- a/frontend/src/api/packets.ts +++ b/frontend/src/api/packets.ts @@ -5,8 +5,11 @@ import type { AttachPagesBody, BomLine, BomPatch, + DeptPerson, FieldCas, Guarded, + HeatTableBody, + HeatTagBody, LogRow, PacketCard, PacketCreate, @@ -15,7 +18,9 @@ import type { PacketRead, ReceiptBody, ShortBody, + StageFields, TransitionBody, + WeldBody, WhStageBody, WorkOrder, } from "./types"; @@ -86,4 +91,33 @@ export const packetsApi = { receiveShort: (id: number, item: number, body: ReceiptBody, g: Guarded = {}) => api.post(`${base(id)}/bom/${item}/short/receipts`, { ...body, ...g }), setWhStage: (id: number, body: WhStageBody, g: Guarded = {}) => api.post(`${base(id)}/wh`, { ...body, ...g }), + + /* Phase 5: the shop. Every answer is the packet. */ + addWeld: (id: number, body: WeldBody, g: Guarded = {}) => api.post(`${base(id)}/welds`, { ...body, ...g }), + /** If-Match is the weld's row_version. */ + patchWeld: (id: number, no: number, rowVersion: number, body: WeldBody, g: Guarded = {}) => + request(`${base(id)}/welds/${no}`, { method: "PATCH", body: { ...body, ...g }, headers: ifMatch(rowVersion) }), + removeWeld: (id: number, no: number, rowVersion: number, body: { reason?: string } & Guarded = {}) => + request(`${base(id)}/welds/${no}`, { method: "DELETE", body, headers: ifMatch(rowVersion) }), + addHeatTag: (id: number, body: HeatTagBody, g: Guarded = {}) => api.post(`${base(id)}/heat-tags`, { ...body, ...g }), + patchHeatTag: (id: number, no: number, rowVersion: number, body: HeatTagBody, g: Guarded = {}) => + request(`${base(id)}/heat-tags/${no}`, { method: "PATCH", body: { ...body, ...g }, headers: ifMatch(rowVersion) }), + removeHeatTag: (id: number, no: number, rowVersion: number, body: { reason?: string } & Guarded = {}) => + request(`${base(id)}/heat-tags/${no}`, { method: "DELETE", body, headers: ifMatch(rowVersion) }), + putHeatTable: (id: number, page: number, body: HeatTableBody, g: Guarded = {}) => + api.put(`${base(id)}/heat-table/${page}`, { ...body, ...g }), + removeHeatTable: (id: number, page: number, g: Guarded = {}) => api.delWithBody(`${base(id)}/heat-table/${page}`, g), + /** The weld record fields with a field-level base: 409 field_conflict when someone changed one first. */ + patchStage: (id: number, body: FieldCas) => api.patch(`${base(id)}/stage`, body), + setChecklist: (id: number, key: string, done: boolean, g: Guarded = {}) => + api.put(`${base(id)}/stage/checklist/${encodeURIComponent(key)}`, { done, ...g }), + clock: (id: number, op: "start" | "pause" | "resume", g: Guarded = {}) => api.post(`${base(id)}/stage/clock`, { op, ...g }), + coVerify: (id: number, no: number, body: { dept: string; note?: string } & Guarded) => + api.post(`${base(id)}/change-orders/${no}/verify`, body), +}; + +/** GET /api/departments/{code}/people: the welder picker and the weld sheet's stamp. */ +export const peopleApi = { + list: async (dept: string, signal?: AbortSignal): Promise => + asList(await api.get(`/api/departments/${encodeURIComponent(dept)}/people`, signal), "people"), }; diff --git a/frontend/src/api/shop.ts b/frontend/src/api/shop.ts new file mode 100644 index 0000000..db42914 --- /dev/null +++ b/frontend/src/api/shop.ts @@ -0,0 +1,155 @@ +// The shop board, the stall and the department people (phase 5 contract, api/shop.py and api/departments.py). +// These types belong to the Supervisor and Stall screens. The packet page types stay in types.ts. +import { api } from "./client"; +import { asList, qs } from "./query"; +import type { PacketCard } from "./types"; + +export interface ShopClock { + /** Live on the server at the time of the answer: stored plus the running time. */ + elapsed_ms: number; + running: boolean; +} + +export interface ShopCo { + label: string; + /** "pending" while Clerical reviews a change from VISUAL, "approved" once it applies. */ + review: string; +} + +/** A card on the shop board: the phase 4 card plus the station, welder, clock and open change orders. */ +export interface ShopCard extends Omit { + welder_stamp?: string | null; + clock?: ShopClock | null; + target_hrs?: number | null; + open_cos: ShopCo[]; + return_note?: string | null; +} + +export interface ShopStation { + id: number; + name: string; + packets: ShopCard[]; +} + +export type BoardLevel = "lead" | "supervisor" | "admin"; + +export interface ShopDept { + code: string; + name: string; + weld: boolean; + level: BoardLevel; + stations: ShopStation[]; +} + +export interface ShopKpis { + ready: number; + at_stations: number; + review: number; + elapsed_ms: number; + target_hrs: number; +} + +/** One open change order on a packet in scope, or one waiting on a department of mine. */ +export interface ShopCoRow { + packet_id: number; + unit_number: string; + op: string | null; + title: string | null; + dept_code: string; + co_no: number; + label: string; + review: string; + hold: boolean; + pending_depts: string[]; + /** I can verify one of the pending departments. */ + mine: boolean; + reason: string | null; + ecn: string | null; + issued_at: string | null; +} + +export const SHOP_COLUMNS = ["ready", "review", "qc", "warehouse", "complete"] as const; +export type ShopColumn = (typeof SHOP_COLUMNS)[number]; + +export interface ShopBoard { + depts: ShopDept[]; + kpis: ShopKpis; + cos: ShopCoRow[]; + columns: Record; +} + +export interface ShopBoardQuery { + /** A department code, or "all". */ + dept?: string; + project_id?: number; + unit_id?: number; +} + +export interface StationOption { + dept_code: string; + dept_name: string; + id: number; + name: string; +} + +export interface StallAnswer { + station: { id: number; name: string; dept_code: string; dept_name: string }; + mine: ShopCard[]; + submitted: ShopCard[]; +} + +export interface DeptPerson { + id: string; + display_name: string; + stamp: string | null; + level: "worker" | "lead" | "supervisor"; + station_limits: number[]; +} + +/** A card with every list present, so the screens never trip on a missing field. */ +export function normaliseCard(raw: ShopCard): ShopCard { + return { ...raw, open_cos: Array.isArray(raw.open_cos) ? raw.open_cos : [] }; +} + +function cards(raw: unknown): ShopCard[] { + return Array.isArray(raw) ? (raw as ShopCard[]).map(normaliseCard) : []; +} + +/** Every department, column and number present, even when the server leaves one out. */ +export function normaliseBoard(raw: Partial | null): ShopBoard { + const cols = (raw?.columns ?? {}) as Partial>; + const columns = Object.fromEntries(SHOP_COLUMNS.map((c) => [c, cards(cols[c])])) as ShopBoard["columns"]; + const depts = (Array.isArray(raw?.depts) ? raw.depts : []).map((d) => ({ + ...d, + stations: (Array.isArray(d.stations) ? d.stations : []).map((s) => ({ ...s, packets: cards(s.packets) })), + })); + return { + depts, + kpis: { ready: 0, at_stations: 0, review: 0, elapsed_ms: 0, target_hrs: 0, ...(raw?.kpis ?? {}) }, + cos: Array.isArray(raw?.cos) ? raw.cos.map((c) => ({ ...c, pending_depts: Array.isArray(c.pending_depts) ? c.pending_depts : [] })) : [], + columns, + }; +} + +export async function getBoard(q: ShopBoardQuery = {}, signal?: AbortSignal): Promise { + return normaliseBoard(await api.get | null>(`/api/shop/board${qs({ ...q })}`, signal)); +} + +export async function getStations(signal?: AbortSignal): Promise { + return asList(await api.get("/api/shop/stations", signal), "stations"); +} + +export async function getStall(stationId: number, signal?: AbortSignal): Promise { + const raw = await api.get | null>(`/api/shop/stall${qs({ station_id: stationId })}`, signal); + return { + station: raw?.station ?? { id: stationId, name: "", dept_code: "", dept_name: "" }, + mine: cards(raw?.mine), + submitted: cards(raw?.submitted), + }; +} + +export async function getPeople(dept: string, signal?: AbortSignal): Promise { + return asList(await api.get(`/api/departments/${encodeURIComponent(dept)}/people`, signal), "people"); +} + +export const shopApi = { getBoard, getStations, getStall, getPeople }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 1da1870..0c413c0 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -227,10 +227,20 @@ export interface PacketPage { pn: number; dwg: string | null; rev: string | null; - void: { co_id: number; at: string; new_n: number | null } | null; + void: PageVoid | null; url: string; } +/** A voided drawing page. Phase 5 adds the CO number, who voided it and the page that replaced it. */ +export interface PageVoid { + co_id: number; + at: string; + new_n: number | null; + co_no?: number | null; + by?: WhoRef | null; + new?: { n: number; dwg: string | null; rev: string | null } | null; +} + export interface BomShort { id: number; qty: number | string; @@ -327,11 +337,11 @@ export interface PacketRead { work_orders: WorkOrder[]; pages: PacketPage[]; bom: BomLine[]; - welds: unknown[]; - heat_tags: unknown[]; - heat_table: unknown[]; - stage: Record | null; - stages: unknown[]; + welds: Weld[]; + heat_tags: HeatTag[]; + heat_table: HeatTablePlacement[]; + stage: Stage | null; + stages: Stage[]; wh: WhInfo | null; qc: Record | null; qc_history: unknown[]; @@ -346,6 +356,178 @@ export interface PacketRead { checklist_items: Array<{ group: string; key: string; text: string }>; qc_items: unknown[]; flag: string | null; + /** Phase 5: the database now(), to tick the clock from. */ + server_now?: string | null; + /** Phase 5: the sum of the work orders' target hours. */ + target_hrs?: number | null; + /** Phase 5: VISUAL labor tickets on the packet's work orders. Null for a hand packet or no match. */ + labor?: Labor | null; + /** Phase 5: the borrowed heat map of a sibling packet, read-only. */ + heat_src?: HeatSrc | null; +} + +/* ---------- Phase 5: the shop on the packet ---------- */ + +export interface Weld { + weld_no: number; + page: number | null; + /** Where the arrow points, as a fraction of the page. */ + fx: number | null; + fy: number | null; + /** The bubble. Null means the default position (geometry.bpos). */ + bfx?: number | null; + bfy?: number | null; + wo_base: string | null; + piece: number | null; + items: number[]; + type: string | null; + size: string | null; + process: string | null; + stamp: string | null; + at: string | null; + row_version: number; + welder: WhoRef | null; + by?: WhoRef | null; + dept_code?: string | null; + stage_id?: number | null; + /** Logged in an earlier department's stage. */ + carried?: boolean; +} + +export interface HeatTag { + tag_no: number; + page: number | null; + item: number; + fx: number | null; + fy: number | null; + bfx?: number | null; + bfy?: number | null; + row_version: number; + carried?: boolean; + carried_from?: number | null; +} + +/** One heat table on a drawing page. x, y is the top-left corner as a fraction; s the size factor. */ +export interface HeatTablePlacement { + page: number; + x: number; + y: number; + s: number; + carried_from?: number | null; + by?: WhoRef | null; + at?: string | null; +} + +export interface StageClock { + /** Live on the server at read time: stored plus the running time. */ + elapsed_ms: number; + stored_ms?: number; + running?: boolean; + running_since: string | null; + started_at: string | null; +} + +export interface ChecklistEntry { + done: boolean; + at?: string | null; + by?: string | null; + by_name?: string | null; + stamp?: string | null; +} + +export interface Stage { + id: number; + dept_code: string; + station: StationRef | null; + welder: WelderRef | null; + checklist: Record; + filler: string | null; + filler_lot: string | null; + preheat: string | null; + notes: string | null; + clock: StageClock; + opened_at: string | null; + closed_at?: string | null; + row_version: number; +} + +/** The weld record fields on the open stage. */ +export interface StageFields { + filler: string | null; + filler_lot: string | null; + preheat: string | null; + notes: string | null; +} + +export interface LaborWo { + wo_base: string; + lot: string; + split: string; + sub: string; + seq: number | null; + hours: number; + tickets: number; + last_ticket: string | null; +} + +export interface Labor { + hours: number; + tickets: number; + per_wo: LaborWo[]; + observed_at: string | null; +} + +export interface HeatSrc { + packet_id: number; + op: string | null; + status: PacketStatus; + drawing: string | null; + rev: string | null; + pages: PacketPage[]; + heat_table: HeatTablePlacement[]; + heat_tags: HeatTag[]; + bom: Array<{ item: number; heat: string | null; na: boolean }>; +} + +/** A weld write. Positions are fractions of the page. */ +export interface WeldBody { + page?: number | null; + fx?: number | null; + fy?: number | null; + bfx?: number | null; + bfy?: number | null; + wo_base?: string | null; + piece?: number | null; + items?: number[]; + type?: string | null; + size?: string | null; + process?: string | null; + stamp?: string | null; +} + +export interface HeatTagBody { + page?: number | null; + item?: number; + fx?: number | null; + fy?: number | null; + bfx?: number | null; + bfy?: number | null; +} + +export interface HeatTableBody { + x: number; + y: number; + s: number; + carried_from?: number | null; +} + +/** One person on GET /api/departments/{code}/people. */ +export interface DeptPerson { + id: string; + display_name: string; + stamp: string | null; + level: "worker" | "lead" | "supervisor"; + station_limits: number[]; } /** One row of GET /api/packets/{id}/log, newest first. */ @@ -541,7 +723,13 @@ export interface ChangeOrder { rejected_reason?: string | null; items: ChangeOrderItem[]; required: string[]; - verified: Record; + verified: Record; + /** Phase 5: "CO-". */ + label?: string; + /** Phase 5: required minus verified. */ + pending_depts?: string[]; + /** Phase 5: approved with pending departments, or pending with hold. */ + open?: boolean; vis?: CoVis | null; waiting?: { dwg: string; rev: string; since?: string | null; err?: string | null } | null; staged?: { asset_id: string; name: string | null; rev: string; dwg: string; sha256?: string; pages?: number | null } | null; diff --git a/frontend/src/auth/roles.test.ts b/frontend/src/auth/roles.test.ts index f8236f7..104cb72 100644 --- a/frontend/src/auth/roles.test.ts +++ b/frontend/src/auth/roles.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { memoryStorage } from "../screens/supervisor/memoryStorage"; import { DEPARTMENTS } from "../test/auth"; import type { Department } from "./types"; -import { firstScreen, homePath, roleChipClass, roleFromKind, roleLabel, screensFor } from "./roles"; +import { firstScreen, homePath, landingScreen, LAST_SCREEN_KEY, levelIn, myShopDepts, rememberScreen, roleChipClass, roleFromKind, roleLabel, screensFor } from "./roles"; const depts = DEPARTMENTS as Department[]; @@ -36,4 +37,43 @@ describe("roles", () => { expect(roleChipClass("admin")).toBe("rchip admin"); expect(roleChipClass("clerical")).toBe("rchip"); }); + + describe("shop departments", () => { + it("levelIn gives the highest level in a department, admin is supervisor everywhere", () => { + expect(levelIn(["shop:03:worker"], "03")).toBe("worker"); + expect(levelIn(["shop:03:worker", "shop:03:lead"], "03")).toBe("lead"); + expect(levelIn(["clerical", "shop:06:supervisor"], "06")).toBe("supervisor"); + expect(levelIn(["shop:06:supervisor"], "03")).toBeNull(); + expect(levelIn(["clerical"], "03")).toBeNull(); + expect(levelIn(["admin"], "09")).toBe("supervisor"); + }); + + it("myShopDepts lists lead and supervisor departments for the Supervisor screen, every one for the Stall", () => { + const roles = ["clerical", "shop:09:worker", "shop:06:supervisor", "shop:03:lead"]; + expect(myShopDepts(roles, "supervisor")).toEqual(["03", "06"]); + expect(myShopDepts(roles, "stall")).toEqual(["03", "06", "09"]); + expect(myShopDepts(["shop:03:worker"], "supervisor")).toEqual([]); + expect(myShopDepts(["shop:03:worker", "shop:03:lead"], "stall")).toEqual(["03"]); + expect(myShopDepts(["admin"], "supervisor", ["14", "03"])).toEqual(["03", "14"]); + expect(myShopDepts(["warehouse"], "stall", ["03"])).toEqual([]); + }); + }); + + describe("landing screen", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", memoryStorage()); + }); + + it("lands on the last screen picked while it is still allowed", () => { + expect(landingScreen(["stall", "supervisor"], null)).toBe("supervisor"); + expect(landingScreen(["stall", "supervisor"], "stall")).toBe("stall"); + expect(landingScreen(["clerical"], "stall")).toBe("clerical"); + rememberScreen("stall"); + expect(window.localStorage.getItem(LAST_SCREEN_KEY)).toBe("stall"); + expect(homePath(["supervisor", "stall"])).toBe("/stall"); + expect(homePath(["clerical", "qc"])).toBe("/clerical"); + window.localStorage.setItem(LAST_SCREEN_KEY, "nonsense"); + expect(homePath(["supervisor", "stall"])).toBe("/supervisor"); + }); + }); }); diff --git a/frontend/src/auth/roles.ts b/frontend/src/auth/roles.ts index fa11164..dbf378d 100644 --- a/frontend/src/auth/roles.ts +++ b/frontend/src/auth/roles.ts @@ -79,17 +79,76 @@ export function orderScreens(list: readonly string[]): Screen[] { return SCREENS.filter((s) => list.includes(s)); } -/** The screen a user lands on: the first non-admin screen, else admin, else null. */ +/** The first non-admin screen, else admin, else null. */ export function firstScreen(list: readonly string[]): Screen | null { const ordered = orderScreens(list); return ordered.find((s) => s !== "admin") ?? ordered[0] ?? null; } +/** localStorage key for the last screen picked in the role switcher (the old build's ps.role). */ +export const LAST_SCREEN_KEY = "ps.role"; + +/** The last screen picked, or null. Storage can be blocked; that reads as nothing remembered. */ +export function lastScreen(): Screen | null { + try { + const v = window.localStorage.getItem(LAST_SCREEN_KEY) ?? undefined; + return isScreen(v) ? v : null; + } catch { + return null; + } +} + +export function rememberScreen(s: Screen): void { + try { + window.localStorage.setItem(LAST_SCREEN_KEY, s); + } catch { + // Nothing to do: the next sign-in lands on the first screen. + } +} + +/** The screen a user lands on: the last one picked while it is still allowed, else the first non-admin screen. */ +export function landingScreen(list: readonly string[], last: Screen | null = lastScreen()): Screen | null { + const ordered = orderScreens(list); + if (last && ordered.includes(last)) return last; + return firstScreen(ordered); +} + export function homePath(list: readonly string[]): string { - const s = firstScreen(list); + const s = landingScreen(list); return s ? `/${s}` : "/account"; } +/** + * The user's level in one shop department (the old levelIn): the highest shop role there, or null. + * Admin counts as supervisor everywhere. + */ +export function levelIn(roles: readonly string[], dept: string): ShopLevel | null { + if (roles.includes("admin")) return "supervisor"; + let best = -1; + for (const r of roles) { + const p = parseRole(r); + if (p.shop && p.dept === dept) best = Math.max(best, SHOP_LEVELS.indexOf(p.level)); + } + return best < 0 ? null : (SHOP_LEVELS[best] ?? null); +} + +/** + * The shop departments a user works in for a screen (the old myDepts), in code order. + * "supervisor": where the user is lead or supervisor. "stall": every shop department at any level. + * Admin gets every shop department in `allShop`. + */ +export function myShopDepts(roles: readonly string[], screen: "supervisor" | "stall", allShop: readonly string[] = []): string[] { + const out = new Set(); + if (roles.includes("admin")) allShop.forEach((d) => out.add(d)); + for (const r of roles) { + const p = parseRole(r); + if (!p.shop) continue; + if (screen === "supervisor" && p.level === "worker") continue; + out.add(p.dept); + } + return [...out].sort((a, b) => a.localeCompare(b)); +} + const OFFICE_LABELS: Record = { admin: "Admin", clerical: "Clerical", diff --git a/frontend/src/auth/useEvents.test.ts b/frontend/src/auth/useEvents.test.ts new file mode 100644 index 0000000..a31eb2c --- /dev/null +++ b/frontend/src/auth/useEvents.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { eventsUrl, othersOn } from "./useEvents"; + +describe("packet presence", () => { + it("opens the stream with the packet in view", () => { + expect(eventsUrl(null)).toBe("/api/events"); + expect(eventsUrl(42)).toBe("/api/events?packet_id=42"); + }); + + it("lists the other people on the packet once each, sorted, without me", () => { + const users = [ + { user_id: "w", display_name: "Wes Super", screen: "supervisor", packet_id: 7 }, + { user_id: "a", display_name: "Andy Welder", screen: "stall", packet_id: "7" }, + { user_id: "a", display_name: "Andy Welder", screen: "stall", packet_id: 7 }, + { user_id: "me", display_name: "Me", screen: "stall", packet_id: 7 }, + { user_id: "q", display_name: "Quinn QC", screen: "qc", packet_id: 8 }, + { user_id: "c", display_name: "Clara Clerk", screen: "clerical", packet_id: null }, + ]; + expect(othersOn(users, 7, "me").map((u) => u.display_name)).toEqual(["Andy Welder", "Wes Super"]); + }); +}); diff --git a/frontend/src/auth/useEvents.ts b/frontend/src/auth/useEvents.ts index 22ec9a3..23cebd3 100644 --- a/frontend/src/auth/useEvents.ts +++ b/frontend/src/auth/useEvents.ts @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { AuthCtx } from "./context"; import type { PresenceUser } from "./types"; interface EventHandlers { @@ -8,6 +9,11 @@ interface EventHandlers { onChanged?: (payload: unknown) => void; } +/** Phase 5: presence carries the packet each user has open. */ +export interface PacketPresenceUser extends PresenceUser { + packet_id?: number | string | null; +} + function parse(data: unknown): unknown { if (typeof data !== "string") return null; try { @@ -17,13 +23,41 @@ function parse(data: unknown): unknown { } } +/* + * The packet this browser is looking at. The packet page sets it (usePacketPresence) and the one EventSource + * reopens with ?packet_id= so the server's presence knows it. A module store keeps AuthProvider unchanged. + */ +let focusPacket: number | null = null; +const focusListeners = new Set<() => void>(); + +export function setPresencePacket(id: number | null): void { + if (focusPacket === id) return; + focusPacket = id; + for (const fn of focusListeners) fn(); +} + +function subscribeFocus(fn: () => void): () => void { + focusListeners.add(fn); + return () => { + focusListeners.delete(fn); + }; +} + +const getFocus = () => focusPacket; + +/** The stream URL for the packet in view. */ +export function eventsUrl(packetId: number | null): string { + return packetId ? `/api/events?packet_id=${encodeURIComponent(String(packetId))}` : "/api/events"; +} + /** * Opens /api/events with EventSource while `sessionId` is set. * A `session` event with this session id and action "revoked" calls onRevoked. - * `presence` is kept in state for later phases. + * `presence` is kept in state. The stream reopens when the packet in view changes. */ export function useEvents(sessionId: string | null, handlers: EventHandlers): PresenceUser[] { const [presence, setPresence] = useState([]); + const packetId = useSyncExternalStore(subscribeFocus, getFocus, getFocus); const ref = useRef(handlers); useEffect(() => { ref.current = handlers; @@ -31,7 +65,7 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr useEffect(() => { if (!sessionId || typeof EventSource === "undefined") return; - const es = new EventSource("/api/events", { withCredentials: true }); + const es = new EventSource(eventsUrl(packetId), { withCredentials: true }); es.addEventListener("session", (ev) => { const p = parse((ev as MessageEvent).data); @@ -59,7 +93,36 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr es.close(); setPresence([]); }; - }, [sessionId]); + }, [sessionId, packetId]); return presence; } + +/** The other people on `packetId`, from the presence list. One entry per user, sorted by name. */ +export function othersOn(users: readonly PacketPresenceUser[], packetId: number, myId: string | null | undefined): PacketPresenceUser[] { + const seen = new Set(); + const out: PacketPresenceUser[] = []; + for (const u of users) { + if (u.packet_id === null || u.packet_id === undefined || Number(u.packet_id) !== packetId) continue; + if (myId && u.user_id === myId) continue; + if (seen.has(u.user_id)) continue; + seen.add(u.user_id); + out.push(u); + } + return out.sort((a, b) => a.display_name.localeCompare(b.display_name)); +} + +/** + * Marks `packetId` as the packet in view while mounted, and returns the other people looking at it. + * Works under AuthProvider; outside it (a bare test render) it returns an empty list. + */ +export function usePacketPresence(packetId: number | null): PacketPresenceUser[] { + const ctx = useContext(AuthCtx); + useEffect(() => { + if (!packetId) return; + setPresencePacket(packetId); + return () => setPresencePacket(null); + }, [packetId]); + if (!ctx || !packetId) return []; + return othersOn(ctx.presence as PacketPresenceUser[], packetId, ctx.me?.user.id); +} diff --git a/frontend/src/components/CoBanner.tsx b/frontend/src/components/CoBanner.tsx new file mode 100644 index 0000000..6eb6a34 --- /dev/null +++ b/frontend/src/components/CoBanner.tsx @@ -0,0 +1,52 @@ +import { useNavigate } from "react-router"; + +export interface CoBannerItem { + key: string; + /** "CO-2". */ + label: string; + /** "L662001 · WELD-PART". */ + job: string; + /** The small line under it: when, for which departments, why. */ + sub?: string; + /** Where the button goes: the packet's Change orders tab. */ + href: string; +} + +/** + * The change order headline (the old coBannerHtml). It has no close button: it stays until the + * department verifies, and the next board answer drops the row. + */ +export function CoBanner({ items, it = false }: { items: readonly CoBannerItem[]; it?: boolean }) { + const navigate = useNavigate(); + if (!items.length) return null; + return ( +
+
+ +
+
{it ? "Change order: verification pending (IT view)" : "Change order: your verification is required"}
+
+ A document in {items.length === 1 ? "this packet" : "these packets"} was replaced. Review the new revision against work already done, then + verify. This stays here until you do. +
+
+
+ {items.map((x) => ( +
+
+
+ Change order {x.label} on {x.job} needs your verification +
+ {x.sub ?
{x.sub}
: null} +
+ +
+ ))} +
+ ); +} diff --git a/frontend/src/components/RoleSwitcher.test.tsx b/frontend/src/components/RoleSwitcher.test.tsx new file mode 100644 index 0000000..d302cbc --- /dev/null +++ b/frontend/src/components/RoleSwitcher.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LAST_SCREEN_KEY } from "../auth/roles"; +import { memoryStorage } from "../screens/supervisor/memoryStorage"; +import { DEPARTMENTS, makeMe, renderApp } from "../test/auth"; +import { mockFetch } from "../test/fetchMock"; + +const LOU = makeMe({ roles: ["shop:03:lead"], screens: ["supervisor", "stall"], user: { sam_account: "lead.lou", display_name: "Lou Lead" } }); + +function routes() { + return { + "/api/auth/me": { body: LOU }, + "/api/departments": { body: DEPARTMENTS }, + "/api/shop/board": { body: {} }, + "/api/shop/stations": { body: [] }, + "/api/projects": { body: [] }, + }; +} + +beforeEach(() => { + vi.stubGlobal("localStorage", memoryStorage()); +}); + +describe("RoleSwitcher", () => { + it("lands on the first screen, and remembers the screen picked", async () => { + mockFetch(routes()); + renderApp("/"); + const nav = await screen.findByRole("navigation", { name: "Screens" }); + await waitFor(() => expect(within(nav).getByRole("button", { name: "Supervisor" })).toHaveAttribute("aria-pressed", "true")); + fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" })); + expect(window.localStorage.getItem(LAST_SCREEN_KEY)).toBe("stall"); + expect(await screen.findByText("You aren't assigned to a station. Ask an admin to add you to a department.")).toBeInTheDocument(); + }); + + it("lands on the remembered screen when it is still allowed", async () => { + window.localStorage.setItem(LAST_SCREEN_KEY, "stall"); + mockFetch(routes()); + renderApp("/"); + const nav = await screen.findByRole("navigation", { name: "Screens" }); + await waitFor(() => expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true")); + }); + + it("ignores a remembered screen the user no longer has", async () => { + window.localStorage.setItem(LAST_SCREEN_KEY, "warehouse"); + mockFetch(routes()); + renderApp("/"); + const nav = await screen.findByRole("navigation", { name: "Screens" }); + await waitFor(() => expect(within(nav).getByRole("button", { name: "Supervisor" })).toHaveAttribute("aria-pressed", "true")); + }); +}); diff --git a/frontend/src/components/RoleSwitcher.tsx b/frontend/src/components/RoleSwitcher.tsx index 67f0787..7748974 100644 --- a/frontend/src/components/RoleSwitcher.tsx +++ b/frontend/src/components/RoleSwitcher.tsx @@ -1,19 +1,27 @@ import { useNavigate } from "react-router"; -import { SCREEN_LABELS, type Screen } from "../auth/roles"; +import { rememberScreen, SCREEN_LABELS, type Screen } from "../auth/roles"; interface RoleSwitcherProps { screens: readonly Screen[]; active: Screen | null; } -/** One button per screen the user has. Drawn only when there is more than one. */ +/** One button per screen the user has. Drawn only when there is more than one. The pick is remembered for the next landing. */ export function RoleSwitcher({ screens, active }: RoleSwitcherProps) { const navigate = useNavigate(); if (screens.length < 2) return null; return (