Phase 8: Project Management, find bar, presence and motion
The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target, Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as "report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates, search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and iPad viewports. Docs updated; PLAN.md questions 38 to 42. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -11,6 +11,15 @@ All notable changes to this project are recorded here. The format follows [Keep
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Phase 8: Project Management, the find bar, presence and motion. The Project Management screen (`/pm`, view only) with the tabs Overview (the headline numbers, a plain-words summary, the pipeline by step with the slowest step in pink, the units late or at risk), Projects & units (every project with its units, due date, days left, packets by step, % done and health: On track, At risk, Late, Complete, No packets yet; a project is as healthy as its worst unit; tap a project for its units and a unit for its jobs), Bottlenecks & aging (time in the current step, every open packet longest waiting first with over 3 days flagged, average time per step for completed moves), Hours vs target (station timer hours and VISUAL ticket hours side by side against the work-order target by department, worker and project, plus every packet over target), Quality & issues (Q.C. rejects, open shorts, open change orders, warehouse holds, sent back) and Find jobs. The period picker (30 days, 90 days, 12 months, all) sets the window for history figures. Every table has Excel (CSV); "Excel (CSV) — all" saves every report; "PDF report" builds the PESCO production status report (cover with the headline numbers, summary and pipeline, projects, flow, labor, issues) on the server with the controlled-copy stamp. Every export is logged as "report exported" in the audit and the print log.
|
||||||
|
- The find bar on every home screen: search over project number or name, lot, operation, drawing and rev, WPS, customer, PO, old project numbers, work orders, heat numbers, part IDs, welder name or stamp, change order and ECN numbers, with several words narrowing and Esc clearing; the project then unit picker; Filters with Department, Status (including At a station and Everything not filed), Station, Worker and, for Clerical and Project Management, the flag kind; the quick flags Change order open, Shorts open, Project overdue, Due within 7 days and Mine (what that screen is responsible for; hidden on the station and Project Management screens); Board or List with the list sortable by last updated, project / lot, due date, status or department. Filters and the view are saved per browser (`ps.flt`); the search text is not. Tapping a number tile shows only those jobs; tapping again clears. The numbers follow the project pick but not the other filters.
|
||||||
|
- Every filter is applied by the server: `GET /api/packets` and the shop, stall, warehouse and Q.C. boards take `q`, `status`, `dept`, `station_id`, `welder_id`, `flags`, `flag`, `sort`, `mine_screen` and `mine_station_id`, and answer `facets` (the stations and workers in the project scope) for the dropdowns. A board never fetches more than it shows.
|
||||||
|
- Presence: the packet page shows "Also in this packet now: Name (role)" for everyone else with the packet open, from the event stream; a locked session drops out at once and a closed tab within the stream's ping cycle. Session events `locked` and `unlocked` are published alongside `revoked`.
|
||||||
|
- Motion: a "Motion on / off" switch in the top bar, saved per browser (`ps.motion`), with reduced-motion honoured: screens and tabs ease in, a card that moved column glides there, a changed card glows, number tiles count up, sheets and toasts slide in. Back returns one page and restores the scroll position. Sheets sit centred on a desktop and at the bottom on a phone, scrolling inside when tall. An iPad-size pass: wide tables scroll sideways, boards stack below 820 px, sheets fit.
|
||||||
|
- The data book compared page by page with one exported from the old build on the same synthetic packet (both 17 pages in the same order): the sheet size rounds half up, the weld times use the old formats, the last closed stage shows its target, a manual change order with no sheets reads "documents changed", table cells wrap where the old tables did and headings sit left. The comparison is recorded in the phase notes.
|
||||||
|
- Tests: the PM aggregates against synthetic data (health rules, periods, pipeline counts, hours), every search field, every filter and sort on the list and the boards, Mine per screen, presence join, leave, lock and revoke, export logging; frontend tests for the find bar state, saved filters, the KPI taps, the job list, motion, scroll restore and the PM tables; Playwright scenarios for the PM screen, the find bar, list, KPI taps, Back, sheets, motion and presence with two users, run at desktop and iPad viewports.
|
||||||
|
- docs/POLICY.md, SECURITY.md and SCHEMA.md updated; PLAN.md open questions 38 to 42.
|
||||||
|
|
||||||
- Phase 7: close, file, PDFs and prints. The "Close and file" panel on the packet page from status complete on: "Back from the shop, ready to close." with what the feed last saw in VISUAL (the job closed since a date, the work orders still open, or not checked yet), Job closed in VISUAL and Not closed in VISUAL, move back; "Ready for the data book." with File in the data book (PIN signature); "Filed in the data book" with the seal line (Seal intact, Seal does not match the record, or Seal unknown) and Reopen (IT). The packet read carries `vclose_check` (the feed's observed and closed times, the open work orders as `wo_base/lot` text, and whether it agrees with the Clerical confirm). Confirming the close while the feed shows open work orders adds them to the log note.
|
- Phase 7: close, file, PDFs and prints. The "Close and file" panel on the packet page from status complete on: "Back from the shop, ready to close." with what the feed last saw in VISUAL (the job closed since a date, the work orders still open, or not checked yet), Job closed in VISUAL and Not closed in VISUAL, move back; "Ready for the data book." with File in the data book (PIN signature); "Filed in the data book" with the seal line (Seal intact, Seal does not match the record, or Seal unknown) and Reopen (IT). The packet read carries `vclose_check` (the feed's observed and closed times, the open work orders as `wo_base/lot` text, and whether it agrees with the Clerical confirm). Confirming the close while the feed shows open work orders adds them to the log note.
|
||||||
- Filing past open work orders: when the feed still shows a work order open in VISUAL, filing answers 409 `open_work_orders` with the list and the screen asks "File with open work orders?" before it sends `confirm_open_wos`. A warning, not a block, on both routes; checked before the signature, so a cancelled confirm costs no PIN attempt. The seal keeps the work orders and the log note says "Filed with work orders open in VISUAL: ...". A hand packet has no warning.
|
- Filing past open work orders: when the feed still shows a work order open in VISUAL, filing answers 409 `open_work_orders` with the list and the screen asks "File with open work orders?" before it sends `confirm_open_wos`. A warning, not a block, on both routes; checked before the signature, so a cancelled confirm costs no PIN attempt. The seal keeps the work orders and the log note says "Filed with work orders open in VISUAL: ...". A hand packet has no warning.
|
||||||
- The data book filed at filing: built on the server in the filing transaction, stored as an asset on the seal with its page count, logged as "Data book filed · N pages". A data book that cannot be built fails the filing (500 `databook_failed`, nothing filed); a part that cannot be read becomes a warning row in the index. Page for page after the old build: the cover (title, figures, equipment and design data, certification, signature lines), the index with page numbers, then 1 Drawings (live sheets at their own size, superseded sheets stamped VOID), 2 Material traceability (BOM and heat numbers, shorts and receipts, open items, the heat map rasters), 3 Welding record (the weld map rasters, a record per stage with the weld log and the station checklist), 4 Q.C. inspection (one set per inspection with checks, visual weld examination, NDE and testing, earlier inspections), 5 Q.C. and test documents (the unit's and project's Q.C. folder documents, ITP first, and the packet documents), 6 Material test reports, 7 Record history (change orders and every packet log row). Before filing, the data book can be viewed or downloaded from status complete on, stamped "not yet filed".
|
- The data book filed at filing: built on the server in the filing transaction, stored as an asset on the seal with its page count, logged as "Data book filed · N pages". A data book that cannot be built fails the filing (500 `databook_failed`, nothing filed); a part that cannot be read becomes a warning row in the index. Page for page after the old build: the cover (title, figures, equipment and design data, certification, signature lines), the index with page numbers, then 1 Drawings (live sheets at their own size, superseded sheets stamped VOID), 2 Material traceability (BOM and heat numbers, shorts and receipts, open items, the heat map rasters), 3 Welding record (the weld map rasters, a record per stage with the weld log and the station checklist), 4 Q.C. inspection (one set per inspection with checks, visual weld examination, NDE and testing, earlier inspections), 5 Q.C. and test documents (the unit's and project's Q.C. folder documents, ITP first, and the packet documents), 6 Material test reports, 7 Record history (change orders and every packet log row). Before filing, the data book can be viewed or downloaded from status complete on, stamped "not yet filed".
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Rebuild in progress. Phase 7 done: close in VISUAL, file with the HMAC seal and the data book, reopen, the server-built PDFs (data book, traveler, ITP), the traceability CSV, and stamped prints with the print log.**
|
**Rebuild in progress. Phase 8 done: the Project Management screen with its reports and exports, the find bar on every home screen with server-side filters and the list view, presence in packets, the motion switch, and the data book checked page by page against the old build.**
|
||||||
|
|
||||||
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. Q.C. inspects the packet on its own tab (the check items, a visual result per weld, NDE, hardness, notes), accepts it to the data book, to Shipping or on to another department with a PIN signature, or rejects it back to the supervisor with a reason or an NCR number. Q.C. keeps project folders of documents per project and unit (ITP, WPS / PQR, procedures, certificates) where a new revision supersedes the old one and nothing is deleted, adds reports to a packet for the data book and takes them out with a reason, and works each unit's digital ITP from the Q.C. template through line initials, N/A, rejects with NCR numbers, section block signatures and a sign-off. Open hold and witness points show as a warning on the unit's packets. Clerical confirms the job closed in VISUAL (the feed's closed state and any open work orders shown beside it) and files the packet with a signature. Filing seals the packet with an HMAC and builds the manufacturing data book on the server, stored on the seal; filing past open work orders needs a second confirmation. IT can reopen a filed packet with a reason and a signature, which keeps the broken seal and its data book. Anyone who can open a packet can view or download the data book, the job traveler with the work order QR codes, the ITP PDF and the traceability CSV, and print a stamped copy of the traveler, the drawings with the heat or weld map, the drawings without markup, the VOID sheets, the data book or any document. Every copy carries a stamp signed with the seal key, and every print and export is logged. The ITP sign-off files the signed ITP PDF. Every packet write goes through the server policy. 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. Q.C. inspects the packet on its own tab (the check items, a visual result per weld, NDE, hardness, notes), accepts it to the data book, to Shipping or on to another department with a PIN signature, or rejects it back to the supervisor with a reason or an NCR number. Q.C. keeps project folders of documents per project and unit (ITP, WPS / PQR, procedures, certificates) where a new revision supersedes the old one and nothing is deleted, adds reports to a packet for the data book and takes them out with a reason, and works each unit's digital ITP from the Q.C. template through line initials, N/A, rejects with NCR numbers, section block signatures and a sign-off. Open hold and witness points show as a warning on the unit's packets. Clerical confirms the job closed in VISUAL (the feed's closed state and any open work orders shown beside it) and files the packet with a signature. Filing seals the packet with an HMAC and builds the manufacturing data book on the server, stored on the seal; filing past open work orders needs a second confirmation. IT can reopen a filed packet with a reason and a signature, which keeps the broken seal and its data book. Anyone who can open a packet can view or download the data book, the job traveler with the work order QR codes, the ITP PDF and the traceability CSV, and print a stamped copy of the traveler, the drawings with the heat or weld map, the drawings without markup, the VOID sheets, the data book or any document. Every copy carries a stamp signed with the seal key, and every print and export is logged. The ITP sign-off files the signed ITP PDF. Every packet write goes through the server policy. 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:.
|
||||||
|
|
||||||
@@ -21,7 +21,8 @@ People can sign in, set up projects and units, make hand drafts, attach a drawin
|
|||||||
| Supervisor and Stall screens, weld map, heat table, station clock, change-order verification | Yes (phase 5, verified in the CI stack on docker-1) |
|
| 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. screen, inspection, Q.C. folders, packet documents, digital ITP, hold-point warnings | Yes (phase 6, verified in the CI stack on docker-1) |
|
| Q.C. screen, inspection, Q.C. folders, packet documents, digital ITP, hold-point warnings | Yes (phase 6, verified in the CI stack on docker-1) |
|
||||||
| Close and file, HMAC seal with the data book, reopen, data book, traveler and ITP PDFs, traceability CSV, prints and the print log | Yes (phase 7) |
|
| Close and file, HMAC seal with the data book, reopen, data book, traveler and ITP PDFs, traceability CSV, prints and the print log | Yes (phase 7) |
|
||||||
| Project Management, Admin, notifications, backups | No (phases 8 and 9) |
|
| Project Management screen (six tabs, periods, health, CSV and PDF reports), the find bar completed (search, filters, quick flags, Mine, Board and List, saved per browser), presence, motion, iPad layout | Yes (phase 8, verified in the CI stack on docker-1) |
|
||||||
|
| Admin completion, notifications, backups | No (phase 9) |
|
||||||
| Regression bench and go-live | No (phase 10) |
|
| Regression bench and go-live | No (phase 10) |
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
@@ -89,6 +90,8 @@ Phase 6 adds the Q.C. screen (`/qc`: numbers, boards, project folders), `GET /ap
|
|||||||
|
|
||||||
Phase 7 adds the close and file panel, `confirm_open_wos` on the file transition (409 `open_work_orders` without it), the data book built and stored on the seal at filing, `GET /api/packets/{id}/pdf/databook`, `GET /api/packets/{id}/pdf/traveler`, `GET /api/packets/{id}/export/traceability.csv`, `GET /api/packets/{id}/print/{kind}` (`traveler`, `map-h`, `map-w`, `clean`, `void`, `databook`, `doc`), `GET /api/packets/{id}/prints`, `GET /api/itps/{id}/pdf` and `GET /api/itps/{id}/print`, and the `print_log` table (migration 0008). The PDFs are built on the server with reportlab 5.0.1, pypdf and pypdfium2; the heat and weld map rasters use Pillow 13.0.0. Both are pinned in `pyproject.toml` and `server/requirements.txt`, and need no system package or font in the image. See [docs/POLICY.md](docs/POLICY.md) "Phase 7: close, file, PDFs and prints".
|
Phase 7 adds the close and file panel, `confirm_open_wos` on the file transition (409 `open_work_orders` without it), the data book built and stored on the seal at filing, `GET /api/packets/{id}/pdf/databook`, `GET /api/packets/{id}/pdf/traveler`, `GET /api/packets/{id}/export/traceability.csv`, `GET /api/packets/{id}/print/{kind}` (`traveler`, `map-h`, `map-w`, `clean`, `void`, `databook`, `doc`), `GET /api/packets/{id}/prints`, `GET /api/itps/{id}/pdf` and `GET /api/itps/{id}/print`, and the `print_log` table (migration 0008). The PDFs are built on the server with reportlab 5.0.1, pypdf and pypdfium2; the heat and weld map rasters use Pillow 13.0.0. Both are pinned in `pyproject.toml` and `server/requirements.txt`, and need no system package or font in the image. See [docs/POLICY.md](docs/POLICY.md) "Phase 7: close, file, PDFs and prints".
|
||||||
|
|
||||||
|
Phase 8 adds the Project Management screen (`/pm`) and its API under `/api/pm` (`overview`, `projects` with `expand=`, `aging`, `hours`, `quality`, `find`, `export.csv?table=<key>|jobs|all`, `report.pdf`), read by any signed-in role and writing nothing; every export is logged as `report exported` in the audit and the print log. The find bar params (`q`, `status`, `dept`, `station_id`, `welder_id`, `flags`, `flag`, `sort`, `mine_screen`, `mine_station_id`, `facets`) are read by `GET /api/packets` and by the shop, stall, warehouse and Q.C. boards, so the server applies every filter; the KPI numbers follow the project pick only. Presence over `/api/events` now carries the role and drops locked sessions (session events `locked` and `unlocked`). No new table or migration. See [docs/POLICY.md](docs/POLICY.md) "Phase 8: Project Management, find bar, presence".
|
||||||
|
|
||||||
CI stack with the Samba domain controller and the Playwright scenario, on a Docker host:
|
CI stack with the Samba domain controller and the Playwright scenario, on a Docker host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+9
-1
@@ -268,11 +268,19 @@ From phase 7 (2026-10-17), for Seth and Clerical to veto:
|
|||||||
36. Filing while the feed still shows a work order open in VISUAL: default a second confirmation (`confirm_open_wos`), a warning and not a block, on both routes. The work orders are kept on the seal and in the log note. A hand packet has no feed state and no warning. Phase 7 (2026-10-17) applied the default.
|
36. Filing while the feed still shows a work order open in VISUAL: default a second confirmation (`confirm_open_wos`), a warning and not a block, on both routes. The work orders are kept on the seal and in the log note. A hand packet has no feed state and no warning. Phase 7 (2026-10-17) applied the default.
|
||||||
37. The controlled-copy stamp carries a short signature made with the seal key, so a printed or exported copy can be checked against the server later. Default: phase 7 ships the signature, the check function and the print log; phase 9 adds the Admin screen that checks a copy. Phase 7 (2026-10-17) applied the default.
|
37. The controlled-copy stamp carries a short signature made with the seal key, so a printed or exported copy can be checked against the server later. Default: phase 7 ships the signature, the check function and the print log; phase 9 adds the Admin screen that checks a copy. Phase 7 (2026-10-17) applied the default.
|
||||||
|
|
||||||
|
From phase 8 (2026-10-18), for Seth to veto. The phase 8 design numbered these 35 to 39; 35 to 37 were already taken by phase 7, so they follow on from 38.
|
||||||
|
|
||||||
|
38. Hours vs target: the station timer stays the clock of record. "Over target" means timer hours over the work-order target. VISUAL ticket hours are shown beside the timer in every hours table and never decide the flag. Phase 8 (2026-10-18) applied the default.
|
||||||
|
39. PM exports and who reads the reports: every PM export is logged as `report exported` in the audit and in `print_log`, with the scope and the period. Default: any signed-in role may read the PM reports (as every role may open every packet), not only PS-ProjectMgmt. Phase 8 (2026-10-18) applied the default.
|
||||||
|
40. Presence shows the display name and the role (the screen the person has open). Default: locked sessions drop out of presence at once; closing the tab drops out within the stream's ping cycle (25 seconds). Phase 8 (2026-10-18) applied the default.
|
||||||
|
41. The find bar filters are applied by the server on every board and list, so a board never fetches more than it shows. Default: the KPI numbers follow the project pick only, not the other filters. Phase 8 (2026-10-18) applied the default.
|
||||||
|
42. Mine is decided from the person's AD roles and the screen they are on. Default: the stall's Mine needs the picked station and shows nothing without one. Phase 8 (2026-10-18) applied the default.
|
||||||
|
|
||||||
## Verification
|
## 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.
|
- 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.
|
||||||
- Phase 3: a recorded DEV feed run compared with the packets the old build made for the same window (counts of drafts, units, projects; same keys), run from the developer machine against VISUAL DEV only.
|
- Phase 3: a recorded DEV feed run compared with the packets the old build made for the same window (counts of drafts, units, projects; same keys), run from the developer machine against VISUAL DEV only.
|
||||||
- Phase 7: server tests for close and file against the policy oracle, the open work order confirm, the seal with its data book, reopen and refile, a failed data book build, the data book sections and index, the traveler, the ITP PDF, every print kind with its stamp and `print_log` row, the CSV formula guard and the stamp signature; frontend tests for the close and file panel, the print menu, the prints list and the ITP buttons; the Playwright close scenario (close, PDFs and CSV, prints, file with the seal, reopen and refile, the signed ITP PDF) in the CI stack on docker-1.
|
- Phase 7: server tests for close and file against the policy oracle, the open work order confirm, the seal with its data book, reopen and refile, a failed data book build, the data book sections and index, the traveler, the ITP PDF, every print kind with its stamp and `print_log` row, the CSV formula guard and the stamp signature; frontend tests for the close and file panel, the print menu, the prints list and the ITP buttons; the Playwright close scenario (close, PDFs and CSV, prints, file with the seal, reopen and refile, the signed ITP PDF) in the CI stack on docker-1.
|
||||||
- Phase 8: the data book of a synthetic filed packet compared page by page with a data book exported from the old build on the same fixture (moved from phase 7).
|
- Phase 8: the data book of a synthetic filed packet compared page by page with a data book exported from the old build on the same fixture (moved from phase 7). Done in phase 8 (2026-10-18); the page table, the fixes and what is left are in the phase 8 comparison note.
|
||||||
- Phase 9: restore rehearsal on the test stack from a pg_dump and an assets copy.
|
- Phase 9: restore rehearsal on the test stack from a pg_dump and an assets copy.
|
||||||
- Phase 10: the full 38-scenario bench green in Gitea Actions.
|
- Phase 10: the full 38-scenario bench green in Gitea Actions.
|
||||||
|
|||||||
+155
-1
@@ -415,7 +415,7 @@ The sibling is a live packet in the same unit, with a different id and the same
|
|||||||
|
|
||||||
### Presence
|
### 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.
|
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. Phase 8 adds the role and the time the person arrived, and drops locked sessions (see "Phase 8: Project Management, find bar, presence").
|
||||||
|
|
||||||
### History texts
|
### History texts
|
||||||
|
|
||||||
@@ -682,6 +682,160 @@ The `action` and `note` of `packet_log` for close, file and reopen.
|
|||||||
|
|
||||||
The audit gains `print` (detail `{kind, what, pages, stamp_sig}`, plus `sheet` and `asset_id` when given) and `export` (detail `{kind, what, pages, stamp_sig}`, kind `databook`, `traveler`, `csv` or `itp`). FILE, ADMIN_REOPEN and SET_VCLOSED write their audit rows through the write pipeline as before.
|
The audit gains `print` (detail `{kind, what, pages, stamp_sig}`, plus `sheet` and `asset_id` when given) and `export` (detail `{kind, what, pages, stamp_sig}`, kind `databook`, `traveler`, `csv` or `itp`). FILE, ADMIN_REOPEN and SET_VCLOSED write their audit rows through the write pipeline as before.
|
||||||
|
|
||||||
|
## Phase 8: Project Management, find bar, presence
|
||||||
|
|
||||||
|
Nothing in the rule table changes in phase 8. Project Management, the find bar filters and presence add no policy action and no write.
|
||||||
|
|
||||||
|
### Who reads the Project Management endpoints
|
||||||
|
|
||||||
|
Every endpoint under `/api/pm` needs a signed-in session. Any role may read them, not only PS-ProjectMgmt, as every role may open every packet (open question 39). An unsigned request is 401. PM writes nothing: there is no POST, PUT, PATCH or DELETE under `/api/pm`, and the PM role passes no write action anywhere else. A packet opened from the PM screen shows "View only." The reports read PostgreSQL only. They never read VISUAL or M-Files.
|
||||||
|
|
||||||
|
| Endpoint | What |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/pm/overview` | The scope, the KPI tiles, the summary lines, the pipeline by step, and the units late or at risk |
|
||||||
|
| `GET /api/pm/projects?expand=all\|<project ids>` | Projects and units, with the units of the opened projects, or every unit with `expand=all` |
|
||||||
|
| `GET /api/pm/aging` | The pipeline and the three aging tables |
|
||||||
|
| `GET /api/pm/hours` | The four hours tables |
|
||||||
|
| `GET /api/pm/quality` | The five quality tables |
|
||||||
|
| `GET /api/pm/find?sort=` | The cards in scope, sorted |
|
||||||
|
| `GET /api/pm/export.csv?table=<key>\|all` | One table, or every table, as CSV. An unknown key is 404 `not_found`. |
|
||||||
|
| `GET /api/pm/report.pdf` | The production status report, inline unless `download=1` |
|
||||||
|
|
||||||
|
### The scope
|
||||||
|
|
||||||
|
Every PM endpoint takes the scope params: `project_id`, `unit_id`, `dept`, `status`, `station_id`, `welder_id`, `flags`, `flag`, `q` and `period`. They mean what they mean on `GET /api/packets` (below). `period` is `30`, `90`, `365` or `all`, default `90`. It limits the history figures only: Q.C. rejects, filed, lead time, sent back and the step history. `mine` in `flags` is ignored on PM.
|
||||||
|
|
||||||
|
- Projects in scope: every project not removed. A closed project only when a packet in scope belongs to it.
|
||||||
|
- Units in scope: the project's units. When any filter other than the project is set, only the units with a packet in scope.
|
||||||
|
- Health follows the old healthOf. `empty` with no packets. `done` when every packet is filed. `late` past the due date. `risk` when due within 7 days and under 75% done, or when any packet has an open change order or a warehouse hold. Else `ok`. A unit uses its own due date, else the project's. A project is as healthy as its worst unit.
|
||||||
|
- Over target means station timer hours over the work-order target hours. VISUAL ticket hours are shown beside the timer and never decide the flag (open question 38).
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
`q` on `GET /api/packets`, the boards and PM. Up to 8 words, and every word must match. A word matches when any field below contains it, case insensitive. A word over 60 characters is cut. The words `lot` and `rev` on their own match everything, so "lot 52" and "rev B" work as they did in the old build.
|
||||||
|
|
||||||
|
- The project: number, name, customer, PO and aliases.
|
||||||
|
- The unit: the unit number (`L670` and the lot) and the lot, both also with the lot's leading zeros stripped, so `52` finds lot `052`.
|
||||||
|
- The packet: op, title, drawing, rev, WPS, customer, the department name, the status label and the flag kinds. The word `flagged` matches any flagged draft.
|
||||||
|
- The station name, and the assigned welder's display name and stamp.
|
||||||
|
- The work orders: the base, and the base with the lot, split and sub (`L489052/1`).
|
||||||
|
- The BOM: heat, part ID, description and material.
|
||||||
|
- Change orders: `CO-<n>`, the bare number and the ECN.
|
||||||
|
- Welds: the stamp and the welder's display name.
|
||||||
|
|
||||||
|
A child match never repeats a packet. IDs are matched as text, never cast.
|
||||||
|
|
||||||
|
### Filters
|
||||||
|
|
||||||
|
The server applies the filters on every list and board (open question 41). A board never fetches more than it shows. A filter only narrows what the person could already read. It never widens access, and each board keeps its own scope checks (a supervisor's departments, the stall's station).
|
||||||
|
|
||||||
|
`status` takes one of the words below, or a comma list of raw statuses as before (`status=ready,review`).
|
||||||
|
|
||||||
|
| value | label | packets |
|
||||||
|
|---|---|---|
|
||||||
|
| draft | Ready to release | draft |
|
||||||
|
| warehouse | With warehouse (any stage) | warehouse |
|
||||||
|
| ready | Ready to assign | ready |
|
||||||
|
| shop | In the shop (ready to Q.C.) | ready, assigned, in_progress, review, qc |
|
||||||
|
| station | At a station (assigned / working) | assigned, in_progress |
|
||||||
|
| review | Supervisor review | review |
|
||||||
|
| qc | At Q.C. | qc |
|
||||||
|
| complete | Accepted / routed | complete |
|
||||||
|
| filed | Filed | filed |
|
||||||
|
| open | Everything not filed | every status but filed |
|
||||||
|
|
||||||
|
The other filters: `dept` (department code), `station_id`, `welder_id` (the assigned welder), `project_id` and `unit_id` (the project and unit pick), `flag` (the flag kind, below) and `flags` (the quick flags, below). The filters are joined with AND.
|
||||||
|
|
||||||
|
### Quick flags and Mine
|
||||||
|
|
||||||
|
`flags` is a comma list.
|
||||||
|
|
||||||
|
| value | chip | packets |
|
||||||
|
|---|---|---|
|
||||||
|
| co | Change order open | an open change order: pending, or approved with verifications still pending |
|
||||||
|
| short | Shorts open | at least one open short |
|
||||||
|
| overdue | Project overdue | the effective due date is before today |
|
||||||
|
| week | Due within 7 days | the effective due date is today to today + 7, or overdue |
|
||||||
|
| mine | Mine | the screen's own work, below |
|
||||||
|
|
||||||
|
Today is the date in the site time zone (`site_tz`, America/Denver by default).
|
||||||
|
|
||||||
|
Mine is decided on the server from the person's AD roles and the screen they name (`mine_screen`), never from anything the browser keeps (open question 42). Without `mine_screen` it is the first screen the roles give. A board defaults it to its own screen.
|
||||||
|
|
||||||
|
| Screen | Mine |
|
||||||
|
|---|---|
|
||||||
|
| clerical | status draft, or status complete routed to the data book |
|
||||||
|
| warehouse | status warehouse, or an open short |
|
||||||
|
| supervisor | the departments where the person is lead or supervisor |
|
||||||
|
| stall | the person's shop departments and the picked station (`mine_station_id`). No station picked: nothing. |
|
||||||
|
| qc | status qc |
|
||||||
|
| pm, or admin with no other role | everything. The chip is hidden on the PM and Stall screens. |
|
||||||
|
|
||||||
|
### Flag kinds
|
||||||
|
|
||||||
|
`flag` applies to drafts only, as the flagged rule does today. The raw values (`1`, `0`, `hand_upload`, a raw kind like `rev_mismatch`) keep working.
|
||||||
|
|
||||||
|
| value | label | rule |
|
||||||
|
|---|---|---|
|
||||||
|
| any | Flagged (any reason) | any flag |
|
||||||
|
| rev | Rev mismatch with M-Files | flag kind rev_mismatch |
|
||||||
|
| unconf | Rev not confirmed yet | a feed draft with its drawing attached (`drawing_state` attached) and `visual.rev_confirmed` not true: the rev approval is still pending |
|
||||||
|
| hand | Made or uploaded by hand | a hand packet, or flag hand_drawing or hand_bom |
|
||||||
|
| none | Still in drafting | flag kind not_released |
|
||||||
|
| vault | Drawing not found in M-Files | flag kind not_found |
|
||||||
|
| read | Drawing couldn't be read | flag kinds lookup_failed or bad_rev |
|
||||||
|
| wait | Drawing being looked up | flag kind drawing_needed |
|
||||||
|
| no | Not flagged | no flag |
|
||||||
|
|
||||||
|
### Sorts
|
||||||
|
|
||||||
|
`sort` on `GET /api/packets` and `GET /api/pm/find`.
|
||||||
|
|
||||||
|
| value | label | order |
|
||||||
|
|---|---|---|
|
||||||
|
| updated | Last updated | newest change first (the default) |
|
||||||
|
| project | Project / lot | project number, then lot, then op, in text order |
|
||||||
|
| due | Due date | the effective due date, earliest first, none last |
|
||||||
|
| status | Status | the workflow order |
|
||||||
|
| dept | Department | department code, then newest change first |
|
||||||
|
|
||||||
|
### Facets
|
||||||
|
|
||||||
|
`facets=1` on `GET /api/packets`, and always on the shop, stall, warehouse and Q.C. boards, adds the stations and assigned welders among the packets in the project and unit pick, ordered by name. The other filters do not narrow the facets. The find bar's Station and Worker lists come from them. The KPI numbers on every home screen follow the project and unit pick only, never the other filters.
|
||||||
|
|
||||||
|
### Exports
|
||||||
|
|
||||||
|
Every PM export writes one `print_log` row and one audit row with the action `report exported`, after the bytes are built (open question 39).
|
||||||
|
|
||||||
|
| Export | Log kind | what | pages |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET /api/pm/export.csv` | `report-csv` | `<table name> · CSV · <scope label>` | none |
|
||||||
|
| `GET /api/pm/report.pdf` | `report-pdf` | `Production status report · PDF · <scope label>` | the page count |
|
||||||
|
|
||||||
|
The rows carry no packet and no unit. The audit detail carries `name`, `format`, `scope` and `period`. The CSV has the formula guard on every cell, a BOM and CRLF line ends. The PDF carries the controlled-copy stamp on every page.
|
||||||
|
|
||||||
|
### Presence
|
||||||
|
|
||||||
|
The packet page shows who else has the packet open: `Also in this packet now: <Name> (<role>)`, joined with ` · `. Nothing is shown when the person is alone.
|
||||||
|
|
||||||
|
- Any signed-in person sees who else is on the same packet. A presence entry is the user id, display name, role, screen, packet id and since. Nothing else (open question 40).
|
||||||
|
- The role is the label of the screen the person has open (`Clerical`, `Warehouse`, `Supervisor`, `Weld Stall`, `Q.C.`, `Project Mgmt`, `Admin`), else the label of their first role.
|
||||||
|
- A locked session drops out at once. Lock and unlock publish a `session` event (`locked`, `unlocked`) from `POST /api/auth/lock`, `POST /api/auth/unlock` and the server's idle lock. Unlock brings the person back.
|
||||||
|
- A revoked session drops out at once, and its stream ends.
|
||||||
|
- Closing the tab or losing the network drops the person out when the stream ends, within the stream's ping cycle (25 seconds). No other idle rule is needed.
|
||||||
|
- The server also keeps who is on each screen for the Admin view of phase 9. No endpoint reads it yet.
|
||||||
|
|
||||||
|
### Tightenings against the old build
|
||||||
|
|
||||||
|
| What the old build allowed | What the rebuild does | Why | Can be vetoed |
|
||||||
|
|---|---|---|---|
|
||||||
|
| The find bar filtered in the browser, over every packet the browser had loaded. | The server applies the search and every filter on each list and board. A filter only narrows what the person may read. | A board never fetches more than it shows. | No |
|
||||||
|
| "Mine" was worked out in the browser from the local session's role. | Mine is decided on the server from the person's AD roles and the screen they name. The stall's Mine needs the picked station. | The roles come from AD, not from what the browser keeps. | Yes |
|
||||||
|
| PM exports were built and saved in the browser, and the browser wrote the `report exported` log line. | Every export is built on the server and logged in `print_log` and the audit as `report exported`, with the scope, the period and, for the PDF, the page count. | The browser alone could skip the log. | Yes |
|
||||||
|
| The PM reports were on the PM home screen only. | Any signed-in role may read them, as any role may open any packet. PM still writes nothing. | The figures come from the same packets every role can already open. | Yes |
|
||||||
|
| Each browser published its own presence, with the name, employee ID, role and stall, refreshed every 20 seconds. | The server builds presence from the session: display name and role, no employee ID. A locked or revoked session drops out at once. | Presence shows only what it needs, and a locked screen is not someone in the packet. | Yes |
|
||||||
|
| Over target was worked out in the browser from the hours it had. | Over target is timer hours over the work-order target, on the server. Ticket hours are shown beside it and never decide it. | The station timer is the clock of record (open question 1). | Yes |
|
||||||
|
|
||||||
## Worker writes (phase 3)
|
## 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.<action>`, role `system`) and a change event. See [FEED.md](FEED.md) for when each write happens.
|
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.<action>`, role `system`) and a change event. See [FEED.md](FEED.md) for when each write happens.
|
||||||
|
|||||||
+10
-3
@@ -1,6 +1,6 @@
|
|||||||
# Packet Station schema
|
# 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 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 6 adds the Q.C. columns, the Q.C. check items, the Q.C. folder metadata, the ITP header, line and block details and the ITP log (migration 0007). Phase 7 adds the print log and the data book on the seal (migration 0008). 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 6 adds the Q.C. columns, the Q.C. check items, the Q.C. folder metadata, the ITP header, line and block details and the ITP log (migration 0007). Phase 7 adds the print log and the data book on the seal (migration 0008). Phase 8 adds no table and no migration: the PM report exports use new `print_log` kinds and presence uses two new session events. 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
|
## Conventions
|
||||||
|
|
||||||
@@ -250,8 +250,8 @@ The texts may hold `{wps}` (else `the WPS`), `{stamp}` (`[75] ` with a trailing
|
|||||||
| `at` | when, default `now()` |
|
| `at` | when, default `now()` |
|
||||||
| `by`, `by_name` | the user and a snapshot of the name |
|
| `by`, `by_name` | the user and a snapshot of the name |
|
||||||
| `session_id` | the session that asked |
|
| `session_id` | the session that asked |
|
||||||
| `packet_id`, `unit_id`, `itp_id` | what was printed; `unit_id` and `itp_id` for an ITP |
|
| `packet_id`, `unit_id`, `itp_id` | what was printed; `unit_id` and `itp_id` for an ITP; all three null for a PM report |
|
||||||
| `kind` | `traveler`, `map-h`, `map-w`, `clean`, `void`, `databook`, `doc`, `itp`, `csv`, `databook-export`, `itp-pdf` |
|
| `kind` | `traveler`, `map-h`, `map-w`, `clean`, `void`, `databook`, `doc`, `itp`, `csv`, `databook-export`, `itp-pdf`; phase 8 adds `report-csv` and `report-pdf` |
|
||||||
| `what` | the human label as printed: `Job traveler`, `Drawing with heat map (sheet 2)`, `MTR - item 3` |
|
| `what` | the human label as printed: `Job traveler`, `Drawing with heat map (sheet 2)`, `MTR - item 3` |
|
||||||
| `pages` | the page count |
|
| `pages` | the page count |
|
||||||
| `asset_id` | the document printed for kind `doc`; the filed data book for `databook` |
|
| `asset_id` | the document printed for kind `doc`; the filed data book for `databook` |
|
||||||
@@ -269,6 +269,13 @@ Indexed on (`packet_id`, `id`) and (`itp_id`, `id`). `GET /api/packets/{id}/prin
|
|||||||
- The packet read shows the newest seal row, broken or not, with its data book, and `seals` (how many rows the packet has).
|
- The packet read shows the newest seal row, broken or not, with its data book, and `seals` (how many rows the packet has).
|
||||||
- The signed ITP PDF filed at sign-off is an `assets` row with `source` `itp_signoff`, stored on `itps.signoff_asset_id`.
|
- The signed ITP PDF filed at sign-off is an `assets` row with `source` `itp_signoff`, stored on `itps.signoff_asset_id`.
|
||||||
|
|
||||||
|
## Project Management and presence (phase 8, no migration)
|
||||||
|
|
||||||
|
Phase 8 adds no table, no column and no migration. The Project Management reports read the existing tables (`packets`, `units`, `projects`, `packet_stages`, `packet_transitions`, `packet_wh`, `qc_history`, `bom_shorts`, `change_orders`, `visual_labor`) and never VISUAL.
|
||||||
|
|
||||||
|
- `print_log` gains two kinds, with no schema change (`kind` is free text): `report-csv` for a PM CSV export and `report-pdf` for the production status report. `packet_id`, `unit_id` and `itp_id` are null on these rows. `what` is `<table name> · CSV · <scope label>` or `Production status report · PDF · <scope label>`. `pages` is the page count for the PDF and null for the CSV. The audit row beside it has the action `report exported`.
|
||||||
|
- `ps_events` (the NOTIFY channel the SSE hub listens on) carries two new session events beside `revoked`: `{"event": "session", "session_id", "action": "locked"}` and `"unlocked"`. They are published by the lock and unlock endpoints and by the server's idle lock, where `sessions.locked_since` is set or cleared. The hub uses them to hide and show that session in presence. Presence itself lives in the app process, not in the database.
|
||||||
|
|
||||||
## Change orders (phases 2 and 3)
|
## 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.
|
- `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.
|
||||||
|
|||||||
+14
-2
@@ -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).
|
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 7. 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). Phase 6 adds the Q.C. rules (server gates on accept, block sign and sign-off, NCR numbers checked by pattern, Q.C. documents never deleted, the append-only ITP log, server-verified signatures on every Q.C. and ITP sign-off). No requirement ID closes in phase 6. Phase 7 adds the close and file controls (the HMAC seal with the data book filed on it, the controlled-copy and print stamps signed with the seal key, `print_log` and audit rows for every print and export, the CSV formula guard, PDF metadata, escaping in the PDFs). Phase 7 closes M2, S6, S7 and S8. Every other control names the phase that builds it.
|
Status: phase 8. 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). Phase 6 adds the Q.C. rules (server gates on accept, block sign and sign-off, NCR numbers checked by pattern, Q.C. documents never deleted, the append-only ITP log, server-verified signatures on every Q.C. and ITP sign-off). No requirement ID closes in phase 6. Phase 7 adds the close and file controls (the HMAC seal with the data book filed on it, the controlled-copy and print stamps signed with the seal key, `print_log` and audit rows for every print and export, the CSV formula guard, PDF metadata, escaping in the PDFs). Phase 7 closes M2, S6, S7 and S8. Phase 8 adds the Project Management reads and exports, the server-side find bar filters and the presence rules (display name and role only, locked and revoked sessions dropped). No requirement ID closes in phase 8. Every other control names the phase that builds it.
|
||||||
|
|
||||||
Contents:
|
Contents:
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
|
|||||||
- A change-order verification needs a signature and is insert-only. A second one for the same department is refused. It cannot be withdrawn.
|
- 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`).
|
- 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.
|
- 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.
|
- Presence over SSE carries display names, never other personal data. Each entry is the user id, display name, screen and packet id. Phase 8 adds the role and since (see "Project Management, find bar and presence").
|
||||||
|
|
||||||
### Q.C. rules (phase 6)
|
### Q.C. rules (phase 6)
|
||||||
|
|
||||||
@@ -165,6 +165,18 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
|
|||||||
- Responses carry `Content-Disposition` (inline, or attachment with `?download=1`), `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`. A print of a document checks that the asset belongs to the packet, its unit or project folder, or its filed data book; anything else is 404.
|
- Responses carry `Content-Disposition` (inline, or attachment with `?download=1`), `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`. A print of a document checks that the asset belongs to the packet, its unit or project folder, or its filed data book; anything else is 404.
|
||||||
- The signed ITP PDF is built and filed in the sign-off transaction. A PDF that cannot be built fails the sign-off.
|
- The signed ITP PDF is built and filed in the sign-off transaction. A PDF that cannot be built fails the sign-off.
|
||||||
|
|
||||||
|
### Project Management, find bar and presence (phase 8)
|
||||||
|
|
||||||
|
- Project Management reads only. Every endpoint under `/api/pm` is a GET and needs a signed-in session. Any role may read them, as any role may open any packet. The PM role passes no write action anywhere. The reports read PostgreSQL only, never VISUAL or M-Files.
|
||||||
|
- Every PM export is built on the server and logged: one `print_log` row (kind `report-csv` or `report-pdf`, no packet, the scope label in `what`, the page count for the PDF) and one audit row with the action `report exported` and the detail `{name, format, scope, period}`. `print_log` stays append-only.
|
||||||
|
- The PM CSV has the formula guard on every cell (S6), a BOM and CRLF line ends. The PDF report carries the controlled-copy stamp on every page and the PDF metadata. Both answer with `Content-Disposition`, `nosniff` and `no-store`, as the phase 7 exports do.
|
||||||
|
- Filters never widen access. The search, the status, flag, station, worker and Mine filters are applied by the server inside each list's and board's own scope (a supervisor's departments, the stall's station). A filter can only narrow what the person may already read.
|
||||||
|
- Search words are bound parameters, matched with ILIKE and the LIKE escape. A query keeps at most 8 words of at most 60 characters each. IDs are matched as text, never cast.
|
||||||
|
- Mine is decided on the server from the person's AD roles and the screen they name. Nothing the browser keeps can widen it.
|
||||||
|
- Presence shows the display name and the role only (the user id, screen, packet id and since travel with it). No employee ID, no email, no other personal data. The server builds it from the session, not from what the browser says about itself.
|
||||||
|
- A locked session drops out of presence at once, and comes back on unlock. A revoked session drops out at once and its stream ends. A closed tab drops out when the stream ends, within the 25-second ping cycle.
|
||||||
|
- Saved filters live in the browser's localStorage (`ps.flt`, `ps.proj`, `ps.pmtab`, `ps.pmper`, `ps.motion`). They hold filter choices only. The search text is never saved. Nothing in them is trusted by the server.
|
||||||
|
|
||||||
### Headers (phase 0)
|
### Headers (phase 0)
|
||||||
|
|
||||||
- The app sets HSTS, CSP and the other security headers itself. NPM's own HSTS is off.
|
- The app sets HSTS, CSP and the other security headers itself. NPM's own HSTS is off.
|
||||||
|
|||||||
@@ -293,7 +293,8 @@ describe("events", () => {
|
|||||||
renderApp("/clerical");
|
renderApp("/clerical");
|
||||||
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||||
const es = sources.at(-1);
|
const es = sources.at(-1);
|
||||||
expect(es?.url).toBe("/api/events");
|
// Phase 8: the stream names the screen in view.
|
||||||
|
expect(es?.url).toBe("/api/events?screen=clerical");
|
||||||
|
|
||||||
act(() => es?.emit("session", { session_id: "other", action: "revoked" }));
|
act(() => es?.emit("session", { session_id: "other", action: "revoked" }));
|
||||||
expect(screen.getByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "Clerical" })).toBeInTheDocument();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
PacketCard,
|
PacketCard,
|
||||||
PacketCreate,
|
PacketCreate,
|
||||||
PacketHeader,
|
PacketHeader,
|
||||||
|
PacketList,
|
||||||
PacketListQuery,
|
PacketListQuery,
|
||||||
PacketRead,
|
PacketRead,
|
||||||
PrintKind,
|
PrintKind,
|
||||||
@@ -28,12 +29,19 @@ import type {
|
|||||||
WhStageBody,
|
WhStageBody,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { normaliseFacets } from "./types";
|
||||||
|
|
||||||
const base = (id: number) => `/api/packets/${encodeURIComponent(String(id))}`;
|
const base = (id: number) => `/api/packets/${encodeURIComponent(String(id))}`;
|
||||||
|
|
||||||
export const packetsApi = {
|
export const packetsApi = {
|
||||||
list: async (q: PacketListQuery = {}, signal?: AbortSignal): Promise<PacketCard[]> =>
|
list: async (q: PacketListQuery = {}, signal?: AbortSignal): Promise<PacketCard[]> =>
|
||||||
asList<PacketCard>(await api.get<unknown>(`/api/packets${qs({ limit: 500, ...q })}`, signal), "packets"),
|
asList<PacketCard>(await api.get<unknown>(`/api/packets${qs({ limit: 500, ...q })}`, signal), "packets"),
|
||||||
|
/** Phase 8: the find bar's list, with the facets for the Station and Worker choices. */
|
||||||
|
find: async (q: PacketListQuery = {}, signal?: AbortSignal): Promise<PacketList> => {
|
||||||
|
const raw = await api.get<unknown>(`/api/packets${qs({ limit: 500, ...q, facets: 1 })}`, signal);
|
||||||
|
const facets = normaliseFacets((raw as { facets?: unknown } | null)?.facets);
|
||||||
|
return { packets: asList<PacketCard>(raw, "packets"), ...(facets ? { facets } : {}) };
|
||||||
|
},
|
||||||
get: (id: number, signal?: AbortSignal) => api.get<PacketRead>(base(id), signal),
|
get: (id: number, signal?: AbortSignal) => api.get<PacketRead>(base(id), signal),
|
||||||
log: async (id: number, opts: { limit?: number; before?: number } = {}, signal?: AbortSignal): Promise<LogRow[]> =>
|
log: async (id: number, opts: { limit?: number; before?: number } = {}, signal?: AbortSignal): Promise<LogRow[]> =>
|
||||||
asList<LogRow>(await api.get<unknown>(`${base(id)}/log${qs({ limit: opts.limit ?? 300, before: opts.before })}`, signal), "log"),
|
asList<LogRow>(await api.get<unknown>(`${base(id)}/log${qs({ limit: opts.limit ?? 300, before: opts.before })}`, signal), "log"),
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// The Project Management reports (phase 8 contract, api/pm.py, prefix /api/pm).
|
||||||
|
// Reads only. Every call takes the scope: the find bar's filters plus the history period.
|
||||||
|
// These types belong to the PM screen. The packet card type stays in types.ts.
|
||||||
|
import { api } from "./client";
|
||||||
|
import { qs } from "./query";
|
||||||
|
import type { Facets, PacketCard } from "./types";
|
||||||
|
|
||||||
|
export type PmPeriod = "30" | "90" | "365" | "all";
|
||||||
|
export type PmTabKey = "overview" | "projects" | "aging" | "hours" | "quality" | "find";
|
||||||
|
export type HealthKey = "ok" | "risk" | "late" | "done" | "empty";
|
||||||
|
|
||||||
|
/** The scope params every PM call takes, as query string values. Built by pmView.scopeParams. */
|
||||||
|
export type PmQuery = Record<string, string>;
|
||||||
|
|
||||||
|
export interface PmScope {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
period: { value: PmPeriod | string; label: string; start: string | null };
|
||||||
|
project: { id: number; number: string; name: string | null } | null;
|
||||||
|
unit: { id: number; number: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PmRow {
|
||||||
|
cells: string[];
|
||||||
|
pid: number | null;
|
||||||
|
project_id: number | null;
|
||||||
|
unit_id: number | null;
|
||||||
|
lvl: 0 | 1;
|
||||||
|
health: HealthKey | null;
|
||||||
|
flag: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PmTable {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
note: string | null;
|
||||||
|
head: string[];
|
||||||
|
rows: PmRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PmKpi {
|
||||||
|
key: string;
|
||||||
|
n: number | string;
|
||||||
|
label: string;
|
||||||
|
tab: PmTabKey | string;
|
||||||
|
alert: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PmStep {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
avg_s: number;
|
||||||
|
max_s: number;
|
||||||
|
oldest: { pid: number; job: string } | null;
|
||||||
|
slowest: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PmOverview {
|
||||||
|
scope: PmScope;
|
||||||
|
kpis: PmKpi[];
|
||||||
|
summary: string[];
|
||||||
|
pipeline: PmStep[];
|
||||||
|
attention: PmTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PmTables {
|
||||||
|
scope: PmScope;
|
||||||
|
tables: PmTable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PmAging extends PmTables {
|
||||||
|
pipeline: PmStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PmFind {
|
||||||
|
scope: PmScope;
|
||||||
|
packets: PacketCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The find bar's Station and Worker choices. */
|
||||||
|
export type PmFacets = Facets;
|
||||||
|
|
||||||
|
/* ---------- normalising ---------- */
|
||||||
|
|
||||||
|
function list<T>(raw: unknown): T[] {
|
||||||
|
return Array.isArray(raw) ? (raw as T[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_SCOPE: PmScope = { label: "", count: 0, period: { value: "90", label: "Last 90 days", start: null }, project: null, unit: null };
|
||||||
|
|
||||||
|
function scopeOf(raw: Partial<PmScope> | null | undefined): PmScope {
|
||||||
|
return { ...EMPTY_SCOPE, ...(raw ?? {}), period: { ...EMPTY_SCOPE.period, ...(raw?.period ?? {}) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowOf(raw: Partial<PmRow>): PmRow {
|
||||||
|
return {
|
||||||
|
cells: list<unknown>(raw.cells).map((c) => (c === null || c === undefined ? "" : String(c))),
|
||||||
|
pid: raw.pid ?? null,
|
||||||
|
project_id: raw.project_id ?? null,
|
||||||
|
unit_id: raw.unit_id ?? null,
|
||||||
|
lvl: raw.lvl === 1 ? 1 : 0,
|
||||||
|
health: raw.health ?? null,
|
||||||
|
flag: !!raw.flag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tableOf(raw: Partial<PmTable> | null | undefined): PmTable {
|
||||||
|
return {
|
||||||
|
key: raw?.key ?? "",
|
||||||
|
title: raw?.title ?? "",
|
||||||
|
note: raw?.note ?? null,
|
||||||
|
head: list<string>(raw?.head),
|
||||||
|
rows: list<Partial<PmRow>>(raw?.rows).map(rowOf),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tablesOf(raw: Partial<PmTables> | null): PmTables {
|
||||||
|
return { scope: scopeOf(raw?.scope), tables: list<Partial<PmTable>>(raw?.tables).map(tableOf) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- calls ---------- */
|
||||||
|
|
||||||
|
const get = <T>(path: string, q: PmQuery, signal?: AbortSignal, extra: PmQuery = {}) => api.get<T>(`${path}${qs({ ...q, ...extra })}`, signal);
|
||||||
|
|
||||||
|
export async function overview(q: PmQuery, signal?: AbortSignal): Promise<PmOverview> {
|
||||||
|
const raw = await get<Partial<PmOverview> | null>("/api/pm/overview", q, signal);
|
||||||
|
return {
|
||||||
|
scope: scopeOf(raw?.scope),
|
||||||
|
kpis: list<PmKpi>(raw?.kpis),
|
||||||
|
summary: list<string>(raw?.summary),
|
||||||
|
pipeline: list<PmStep>(raw?.pipeline),
|
||||||
|
attention: tableOf(raw?.attention),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `expand` is "all" or the opened project ids. Without it, only the picked project shows its units. */
|
||||||
|
export async function projects(q: PmQuery, expand: string, signal?: AbortSignal): Promise<PmTables> {
|
||||||
|
return tablesOf(await get<Partial<PmTables> | null>("/api/pm/projects", q, signal, expand ? { expand } : {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aging(q: PmQuery, signal?: AbortSignal): Promise<PmAging> {
|
||||||
|
const raw = await get<Partial<PmAging> | null>("/api/pm/aging", q, signal);
|
||||||
|
return { ...tablesOf(raw), pipeline: list<PmStep>(raw?.pipeline) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hours(q: PmQuery, signal?: AbortSignal): Promise<PmTables> {
|
||||||
|
return tablesOf(await get<Partial<PmTables> | null>("/api/pm/hours", q, signal));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function quality(q: PmQuery, signal?: AbortSignal): Promise<PmTables> {
|
||||||
|
return tablesOf(await get<Partial<PmTables> | null>("/api/pm/quality", q, signal));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function find(q: PmQuery, sort: string, signal?: AbortSignal): Promise<PmFind> {
|
||||||
|
const raw = await get<Partial<PmFind> | null>("/api/pm/find", q, signal, sort ? { sort } : {});
|
||||||
|
return { scope: scopeOf(raw?.scope), packets: list<PacketCard>(raw?.packets) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The find bar's Station and Worker choices. The PM answers carry no facets, so this asks
|
||||||
|
* GET /api/packets for them with the project and unit only (facets ignore the other filters).
|
||||||
|
*/
|
||||||
|
export async function facets(q: PmQuery, signal?: AbortSignal): Promise<PmFacets> {
|
||||||
|
const raw = await api.get<{ facets?: Partial<PmFacets> } | null>(
|
||||||
|
`/api/packets${qs({ project_id: q.project_id, unit_id: q.unit_id, facets: 1, limit: 1 })}`,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
return { stations: list(raw?.facets?.stations), workers: list(raw?.facets?.workers) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One table (by key), "jobs", or "all", as a CSV download. */
|
||||||
|
export const csvUrl = (table: string, q: PmQuery) => `/api/pm/export.csv${qs({ table, ...q })}`;
|
||||||
|
|
||||||
|
/** The production status report. */
|
||||||
|
export const pdfUrl = (q: PmQuery) => `/api/pm/report.pdf${qs({ ...q })}`;
|
||||||
|
|
||||||
|
export const pmApi = { overview, projects, aging, hours, quality, find, facets, csvUrl, pdfUrl };
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// These types belong to the Q.C. screens. The packet page types stay in types.ts.
|
// These types belong to the Q.C. screens. The packet page types stay in types.ts.
|
||||||
import { api, request } from "./client";
|
import { api, request } from "./client";
|
||||||
import { qs } from "./query";
|
import { qs } from "./query";
|
||||||
import type { Guarded, PacketCard, ProjectStatus, SignBody } from "./types";
|
import { normaliseFacets, type BoardQuery, type Facets, type Guarded, type PacketCard, type ProjectStatus, type SignBody } from "./types";
|
||||||
|
|
||||||
/** A person on a record. */
|
/** A person on a record. */
|
||||||
export interface QcWho {
|
export interface QcWho {
|
||||||
@@ -52,12 +52,12 @@ export interface QcHome {
|
|||||||
kpis: QcKpis;
|
kpis: QcKpis;
|
||||||
columns: Record<QcColumn, QcCard[]>;
|
columns: Record<QcColumn, QcCard[]>;
|
||||||
folders: QcFolderCard[];
|
folders: QcFolderCard[];
|
||||||
|
/** Phase 8: the find bar's Station and Worker choices. */
|
||||||
|
facets?: Facets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QcHomeQuery {
|
/** Phase 8: the find bar's query. KPIs follow the project and unit only. */
|
||||||
project_id?: number;
|
export type QcHomeQuery = BoardQuery;
|
||||||
unit_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Q.C. folders ---------- */
|
/* ---------- Q.C. folders ---------- */
|
||||||
|
|
||||||
@@ -298,10 +298,12 @@ function list<T>(raw: unknown): T[] {
|
|||||||
export function normaliseHome(raw: Partial<QcHome> | null): QcHome {
|
export function normaliseHome(raw: Partial<QcHome> | null): QcHome {
|
||||||
const cols = (raw?.columns ?? {}) as Partial<Record<QcColumn, unknown>>;
|
const cols = (raw?.columns ?? {}) as Partial<Record<QcColumn, unknown>>;
|
||||||
const columns = Object.fromEntries(QC_COLUMNS.map((c) => [c, list<QcCard>(cols[c])])) as QcHome["columns"];
|
const columns = Object.fromEntries(QC_COLUMNS.map((c) => [c, list<QcCard>(cols[c])])) as QcHome["columns"];
|
||||||
|
const facets = normaliseFacets(raw?.facets);
|
||||||
return {
|
return {
|
||||||
kpis: { waiting: 0, accepted_7d: 0, rejected_7d: 0, ...(raw?.kpis ?? {}) },
|
kpis: { waiting: 0, accepted_7d: 0, rejected_7d: 0, ...(raw?.kpis ?? {}) },
|
||||||
columns,
|
columns,
|
||||||
folders: list<QcFolderCard>(raw?.folders),
|
folders: list<QcFolderCard>(raw?.folders),
|
||||||
|
...(facets ? { facets } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// These types belong to the Supervisor and Stall screens. The packet page types stay in types.ts.
|
// These types belong to the Supervisor and Stall screens. The packet page types stay in types.ts.
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import { asList, qs } from "./query";
|
import { asList, qs } from "./query";
|
||||||
import type { PacketCard } from "./types";
|
import { normaliseFacets, type BoardQuery, type Facets, type PacketCard } from "./types";
|
||||||
|
|
||||||
export interface ShopClock {
|
export interface ShopClock {
|
||||||
/** Live on the server at the time of the answer: stored plus the running time. */
|
/** Live on the server at the time of the answer: stored plus the running time. */
|
||||||
@@ -76,14 +76,12 @@ export interface ShopBoard {
|
|||||||
kpis: ShopKpis;
|
kpis: ShopKpis;
|
||||||
cos: ShopCoRow[];
|
cos: ShopCoRow[];
|
||||||
columns: Record<ShopColumn, ShopCard[]>;
|
columns: Record<ShopColumn, ShopCard[]>;
|
||||||
|
/** Phase 8: the find bar's Station and Worker choices. */
|
||||||
|
facets?: Facets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopBoardQuery {
|
/** The find bar's query plus the department scope. dept is a code, or "all". */
|
||||||
/** A department code, or "all". */
|
export type ShopBoardQuery = BoardQuery;
|
||||||
dept?: string;
|
|
||||||
project_id?: number;
|
|
||||||
unit_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StationOption {
|
export interface StationOption {
|
||||||
dept_code: string;
|
dept_code: string;
|
||||||
@@ -96,6 +94,7 @@ export interface StallAnswer {
|
|||||||
station: { id: number; name: string; dept_code: string; dept_name: string };
|
station: { id: number; name: string; dept_code: string; dept_name: string };
|
||||||
mine: ShopCard[];
|
mine: ShopCard[];
|
||||||
submitted: ShopCard[];
|
submitted: ShopCard[];
|
||||||
|
facets?: Facets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeptPerson {
|
export interface DeptPerson {
|
||||||
@@ -123,11 +122,13 @@ export function normaliseBoard(raw: Partial<ShopBoard> | null): ShopBoard {
|
|||||||
...d,
|
...d,
|
||||||
stations: (Array.isArray(d.stations) ? d.stations : []).map((s) => ({ ...s, packets: cards(s.packets) })),
|
stations: (Array.isArray(d.stations) ? d.stations : []).map((s) => ({ ...s, packets: cards(s.packets) })),
|
||||||
}));
|
}));
|
||||||
|
const facets = normaliseFacets(raw?.facets);
|
||||||
return {
|
return {
|
||||||
depts,
|
depts,
|
||||||
kpis: { ready: 0, at_stations: 0, review: 0, elapsed_ms: 0, target_hrs: 0, ...(raw?.kpis ?? {}) },
|
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 : [] })) : [],
|
cos: Array.isArray(raw?.cos) ? raw.cos.map((c) => ({ ...c, pending_depts: Array.isArray(c.pending_depts) ? c.pending_depts : [] })) : [],
|
||||||
columns,
|
columns,
|
||||||
|
...(facets ? { facets } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,12 +140,15 @@ export async function getStations(signal?: AbortSignal): Promise<StationOption[]
|
|||||||
return asList<StationOption>(await api.get<unknown>("/api/shop/stations", signal), "stations");
|
return asList<StationOption>(await api.get<unknown>("/api/shop/stations", signal), "stations");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStall(stationId: number, signal?: AbortSignal): Promise<StallAnswer> {
|
/** The stall's two columns. Phase 8: the find bar's query applies to the station's cards. */
|
||||||
const raw = await api.get<Partial<StallAnswer> | null>(`/api/shop/stall${qs({ station_id: stationId })}`, signal);
|
export async function getStall(stationId: number, signal?: AbortSignal, q: BoardQuery = {}): Promise<StallAnswer> {
|
||||||
|
const raw = await api.get<Partial<StallAnswer> | null>(`/api/shop/stall${qs({ station_id: stationId, ...q })}`, signal);
|
||||||
|
const facets = normaliseFacets(raw?.facets);
|
||||||
return {
|
return {
|
||||||
station: raw?.station ?? { id: stationId, name: "", dept_code: "", dept_name: "" },
|
station: raw?.station ?? { id: stationId, name: "", dept_code: "", dept_name: "" },
|
||||||
mine: cards(raw?.mine),
|
mine: cards(raw?.mine),
|
||||||
submitted: cards(raw?.submitted),
|
submitted: cards(raw?.submitted),
|
||||||
|
...(facets ? { facets } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,20 +188,61 @@ export interface PacketCard {
|
|||||||
released_at?: string | null;
|
released_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Phase 8: the find bar's Station and Worker choices, from the packets in the project and unit scope. */
|
||||||
|
export interface FacetStation {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
dept_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacetWorker {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Facets {
|
||||||
|
stations: FacetStation[];
|
||||||
|
workers: FacetWorker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The facets of an answer, with both lists present, or null when the server sent none. */
|
||||||
|
export function normaliseFacets(raw: unknown): Facets | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const r = raw as Partial<Facets>;
|
||||||
|
return { stations: Array.isArray(r.stations) ? r.stations : [], workers: Array.isArray(r.workers) ? r.workers : [] };
|
||||||
|
}
|
||||||
|
|
||||||
export interface PacketList {
|
export interface PacketList {
|
||||||
packets: PacketCard[];
|
packets: PacketCard[];
|
||||||
|
/** Phase 8: present when asked with facets=1. */
|
||||||
|
facets?: Facets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PacketListQuery {
|
export interface PacketListQuery {
|
||||||
|
/** A find bar status word (draft, warehouse, shop, station, open, ...) or a comma list of raw statuses. */
|
||||||
status?: string;
|
status?: string;
|
||||||
dept?: string;
|
dept?: string;
|
||||||
unit_id?: number;
|
unit_id?: number;
|
||||||
project_id?: number;
|
project_id?: number;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
/** A flag kind (any, rev, unconf, hand, none, vault, read, wait, no) or an older raw value. */
|
||||||
flag?: string;
|
flag?: string;
|
||||||
/** "pending": packets with a pending feed change order. */
|
/** "pending": packets with a pending feed change order. */
|
||||||
co?: string;
|
co?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
/* Phase 8: the find bar. */
|
||||||
|
station_id?: number;
|
||||||
|
/** The assigned welder's user id (uuid text). */
|
||||||
|
welder_id?: string;
|
||||||
|
/** Quick flags as a comma list: co, short, overdue, week, mine. */
|
||||||
|
flags?: string;
|
||||||
|
/** updated (default), project, due, status, dept. */
|
||||||
|
sort?: string;
|
||||||
|
/** 1 adds facets to the answer. */
|
||||||
|
facets?: number;
|
||||||
|
/** The screen that decides what Mine means. */
|
||||||
|
mine_screen?: string;
|
||||||
|
mine_station_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrder {
|
export interface WorkOrder {
|
||||||
@@ -1164,14 +1205,12 @@ export interface BoardResponse {
|
|||||||
kpis: { waiting: number; pulling: number; hold: number; pulled: number; open_shorts: number; released_24h: number };
|
kpis: { waiting: number; pulling: number; hold: number; pulled: number; open_shorts: number; released_24h: number };
|
||||||
columns: Record<BoardColumn, PacketCard[]>;
|
columns: Record<BoardColumn, PacketCard[]>;
|
||||||
shorts: ShortRow[];
|
shorts: ShortRow[];
|
||||||
|
/** Phase 8: the find bar's Station and Worker choices. */
|
||||||
|
facets?: Facets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoardQuery {
|
/** Phase 8: every board takes the find bar's query. KPIs follow the project and unit only. */
|
||||||
q?: string;
|
export type BoardQuery = Omit<PacketListQuery, "limit" | "co" | "facets">;
|
||||||
project_id?: number;
|
|
||||||
unit_id?: number;
|
|
||||||
dept?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Phase 4: storage and alerts (Admin) ---------- */
|
/* ---------- Phase 4: storage and alerts (Admin) ---------- */
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// The warehouse board (phase 4 contract, api/warehouse.py).
|
// The warehouse board (phase 4 contract, api/warehouse.py).
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import { qs } from "./query";
|
import { qs } from "./query";
|
||||||
import type { BoardQuery, BoardResponse, PacketCard } from "./types";
|
import { normaliseFacets, type BoardQuery, type BoardResponse, type PacketCard } from "./types";
|
||||||
|
|
||||||
const COLUMNS = ["incoming", "pulling", "hold", "pulled", "released"] as const;
|
const COLUMNS = ["incoming", "pulling", "hold", "pulled", "released"] as const;
|
||||||
|
|
||||||
@@ -9,10 +9,12 @@ const COLUMNS = ["incoming", "pulling", "hold", "pulled", "released"] as const;
|
|||||||
function normalise(raw: Partial<BoardResponse> | null): BoardResponse {
|
function normalise(raw: Partial<BoardResponse> | null): BoardResponse {
|
||||||
const cols = (raw?.columns ?? {}) as Partial<Record<(typeof COLUMNS)[number], PacketCard[]>>;
|
const cols = (raw?.columns ?? {}) as Partial<Record<(typeof COLUMNS)[number], PacketCard[]>>;
|
||||||
const columns = Object.fromEntries(COLUMNS.map((c) => [c, Array.isArray(cols[c]) ? cols[c] : []])) as BoardResponse["columns"];
|
const columns = Object.fromEntries(COLUMNS.map((c) => [c, Array.isArray(cols[c]) ? cols[c] : []])) as BoardResponse["columns"];
|
||||||
|
const facets = normaliseFacets(raw?.facets);
|
||||||
return {
|
return {
|
||||||
kpis: { waiting: 0, pulling: 0, hold: 0, pulled: 0, open_shorts: 0, released_24h: 0, ...(raw?.kpis ?? {}) },
|
kpis: { waiting: 0, pulling: 0, hold: 0, pulled: 0, open_shorts: 0, released_24h: 0, ...(raw?.kpis ?? {}) },
|
||||||
columns,
|
columns,
|
||||||
shorts: Array.isArray(raw?.shorts) ? raw.shorts : [],
|
shorts: Array.isArray(raw?.shorts) ? raw.shorts : [],
|
||||||
|
...(facets ? { facets } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { authApi, departmentsApi } from "./api";
|
|||||||
import { AuthCtx, type AuthState, type AuthStatus } from "./context";
|
import { AuthCtx, type AuthState, type AuthStatus } from "./context";
|
||||||
import { roleLabel as labelFor } from "./roles";
|
import { roleLabel as labelFor } from "./roles";
|
||||||
import type { Department, Factor, Me } from "./types";
|
import type { Department, Factor, Me } from "./types";
|
||||||
import { useEvents } from "./useEvents";
|
import { useLocation } from "react-router";
|
||||||
|
import { screenOf, useEvents } from "./useEvents";
|
||||||
|
|
||||||
/** The old build's screen lock. The server locks at SESSION_LOCK_MINUTES too; it is the authority. */
|
/** The old build's screen lock. The server locks at SESSION_LOCK_MINUTES too; it is the authority. */
|
||||||
export const IDLE_LOCK_MS = 3 * 60_000;
|
export const IDLE_LOCK_MS = 3 * 60_000;
|
||||||
@@ -202,10 +203,17 @@ export function AuthProvider({ children, idleLockMs = IDLE_LOCK_MS, idleCheckMs
|
|||||||
}, [status, idleLockMs, idleCheckMs, lock]);
|
}, [status, idleLockMs, idleCheckMs, lock]);
|
||||||
|
|
||||||
const sessionId = status === "ready" || status === "locked" ? (me?.session.id ?? null) : null;
|
const sessionId = status === "ready" || status === "locked" ? (me?.session.id ?? null) : null;
|
||||||
const presence = useEvents(sessionId, {
|
// Phase 8: the stream names the screen in view, so presence can show it as the role.
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const presence = useEvents(
|
||||||
|
sessionId,
|
||||||
|
{
|
||||||
onRevoked: () => endSession("This session was signed out from another place."),
|
onRevoked: () => endSession("This session was signed out from another place."),
|
||||||
onChanged: emitChanged,
|
onChanged: emitChanged,
|
||||||
});
|
onLockChange: () => void refresh(),
|
||||||
|
},
|
||||||
|
{ screen: screenOf(pathname), locked: status === "locked" },
|
||||||
|
);
|
||||||
|
|
||||||
const roleLabel = useCallback((role: string) => labelFor(role, departments), [departments]);
|
const roleLabel = useCallback((role: string) => labelFor(role, departments), [departments]);
|
||||||
|
|
||||||
|
|||||||
@@ -191,4 +191,8 @@ export interface PresenceUser {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
screen: string | null;
|
screen: string | null;
|
||||||
|
/** Phase 8: the screen label of the stream (Clerical, Weld Stall, ...), else the person's primary role label. */
|
||||||
|
role?: string | null;
|
||||||
|
/** Phase 8: when this stream joined (ISO). */
|
||||||
|
since?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,37 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { eventsUrl, othersOn } from "./useEvents";
|
import { eventsUrl, othersOn, presenceText, screenOf } from "./useEvents";
|
||||||
|
|
||||||
describe("packet presence", () => {
|
describe("packet presence", () => {
|
||||||
it("opens the stream with the packet in view", () => {
|
it("opens the stream with the packet in view", () => {
|
||||||
expect(eventsUrl(null)).toBe("/api/events");
|
expect(eventsUrl(null)).toBe("/api/events");
|
||||||
expect(eventsUrl(42)).toBe("/api/events?packet_id=42");
|
expect(eventsUrl(42)).toBe("/api/events?packet_id=42");
|
||||||
|
expect(eventsUrl(42, "supervisor")).toBe("/api/events?screen=supervisor&packet_id=42");
|
||||||
|
expect(eventsUrl(null, "qc")).toBe("/api/events?screen=qc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the screen from the first path segment", () => {
|
||||||
|
expect(screenOf("/supervisor")).toBe("supervisor");
|
||||||
|
expect(screenOf("/clerical/units/4")).toBe("clerical");
|
||||||
|
expect(screenOf("/packets/7")).toBeNull();
|
||||||
|
expect(screenOf("/")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the callout names each person with the role, and is empty when alone", () => {
|
||||||
|
expect(presenceText([])).toBe("");
|
||||||
|
const users = [
|
||||||
|
{ user_id: "a", display_name: "Andy Welder", screen: "stall", role: "Weld Stall", packet_id: 7 },
|
||||||
|
{ user_id: "w", display_name: "Wes Super", screen: "supervisor", packet_id: 7 },
|
||||||
|
{ user_id: "x", display_name: "Xena", screen: null, packet_id: 7 },
|
||||||
|
];
|
||||||
|
expect(presenceText(othersOn(users, 7, "me"))).toBe("Also in this packet now: Andy Welder (Weld Stall) · Wes Super (Supervisor) · Xena");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the entry that carries a role", () => {
|
||||||
|
const users = [
|
||||||
|
{ user_id: "a", display_name: "Andy Welder", screen: null, packet_id: 7 },
|
||||||
|
{ user_id: "a", display_name: "Andy Welder", screen: "stall", role: "Weld Stall", packet_id: 7 },
|
||||||
|
];
|
||||||
|
expect(othersOn(users, 7, null)[0]?.role).toBe("Weld Stall");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists the other people on the packet once each, sorted, without me", () => {
|
it("lists the other people on the packet once each, sorted, without me", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import { AuthCtx } from "./context";
|
import { AuthCtx } from "./context";
|
||||||
|
import { isScreen, SCREEN_LABELS } from "./roles";
|
||||||
import type { PresenceUser } from "./types";
|
import type { PresenceUser } from "./types";
|
||||||
|
|
||||||
interface EventHandlers {
|
interface EventHandlers {
|
||||||
@@ -7,6 +8,15 @@ interface EventHandlers {
|
|||||||
onRevoked: () => void;
|
onRevoked: () => void;
|
||||||
/** Called for every `changed` event. Later phases refresh their data from it. */
|
/** Called for every `changed` event. Later phases refresh their data from it. */
|
||||||
onChanged?: (payload: unknown) => void;
|
onChanged?: (payload: unknown) => void;
|
||||||
|
/** Phase 8: this browser's own session was locked or unlocked, here or in another tab. */
|
||||||
|
onLockChange?: (action: "locked" | "unlocked") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventsOptions {
|
||||||
|
/** The screen in view (the first path segment when it is a screen). The server shows it as the role. */
|
||||||
|
screen?: string | null;
|
||||||
|
/** While locked the stream stays open but takes no new screen or packet. */
|
||||||
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Phase 5: presence carries the packet each user has open. */
|
/** Phase 5: presence carries the packet each user has open. */
|
||||||
@@ -45,9 +55,19 @@ function subscribeFocus(fn: () => void): () => void {
|
|||||||
|
|
||||||
const getFocus = () => focusPacket;
|
const getFocus = () => focusPacket;
|
||||||
|
|
||||||
/** The stream URL for the packet in view. */
|
/** The stream URL for the screen and the packet in view. */
|
||||||
export function eventsUrl(packetId: number | null): string {
|
export function eventsUrl(packetId: number | null, screen: string | null = null): string {
|
||||||
return packetId ? `/api/events?packet_id=${encodeURIComponent(String(packetId))}` : "/api/events";
|
const sp = new URLSearchParams();
|
||||||
|
if (screen) sp.set("screen", screen);
|
||||||
|
if (packetId) sp.set("packet_id", String(packetId));
|
||||||
|
const q = sp.toString();
|
||||||
|
return q ? `/api/events?${q}` : "/api/events";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The screen of a path: its first segment when that is a screen. */
|
||||||
|
export function screenOf(pathname: string): string | null {
|
||||||
|
const seg = pathname.split("/")[1];
|
||||||
|
return isScreen(seg) ? seg : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,23 +75,30 @@ export function eventsUrl(packetId: number | null): string {
|
|||||||
* A `session` event with this session id and action "revoked" calls onRevoked.
|
* A `session` event with this session id and action "revoked" calls onRevoked.
|
||||||
* `presence` is kept in state. The stream reopens when the packet in view changes.
|
* `presence` is kept in state. The stream reopens when the packet in view changes.
|
||||||
*/
|
*/
|
||||||
export function useEvents(sessionId: string | null, handlers: EventHandlers): PresenceUser[] {
|
export function useEvents(sessionId: string | null, handlers: EventHandlers, opts: EventsOptions = {}): PresenceUser[] {
|
||||||
const [presence, setPresence] = useState<PresenceUser[]>([]);
|
const [presence, setPresence] = useState<PresenceUser[]>([]);
|
||||||
const packetId = useSyncExternalStore(subscribeFocus, getFocus, getFocus);
|
const focus = useSyncExternalStore(subscribeFocus, getFocus, getFocus);
|
||||||
const ref = useRef(handlers);
|
const ref = useRef(handlers);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ref.current = handlers;
|
ref.current = handlers;
|
||||||
});
|
});
|
||||||
|
// While locked the stream keeps what it had: nothing new is sent about where this browser is.
|
||||||
|
const [held, setHeld] = useState<{ packetId: number | null; screen: string | null }>({ packetId: focus, screen: opts.screen ?? null });
|
||||||
|
const wantScreen = opts.screen ?? null;
|
||||||
|
if (!opts.locked && (held.packetId !== focus || held.screen !== wantScreen)) setHeld({ packetId: focus, screen: wantScreen });
|
||||||
|
const { packetId, screen } = held;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId || typeof EventSource === "undefined") return;
|
if (!sessionId || typeof EventSource === "undefined") return;
|
||||||
const es = new EventSource(eventsUrl(packetId), { withCredentials: true });
|
const es = new EventSource(eventsUrl(packetId, screen), { withCredentials: true });
|
||||||
|
|
||||||
es.addEventListener("session", (ev) => {
|
es.addEventListener("session", (ev) => {
|
||||||
const p = parse((ev as MessageEvent).data);
|
const p = parse((ev as MessageEvent).data);
|
||||||
if (p && typeof p === "object") {
|
if (p && typeof p === "object") {
|
||||||
const o = p as { session_id?: unknown; action?: unknown };
|
const o = p as { session_id?: unknown; action?: unknown };
|
||||||
if (o.session_id === sessionId && o.action === "revoked") ref.current.onRevoked();
|
if (o.session_id !== sessionId) return;
|
||||||
|
if (o.action === "revoked") ref.current.onRevoked();
|
||||||
|
else if (o.action === "locked" || o.action === "unlocked") ref.current.onLockChange?.(o.action);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
es.addEventListener("presence", (ev) => {
|
es.addEventListener("presence", (ev) => {
|
||||||
@@ -93,23 +120,37 @@ export function useEvents(sessionId: string | null, handlers: EventHandlers): Pr
|
|||||||
es.close();
|
es.close();
|
||||||
setPresence([]);
|
setPresence([]);
|
||||||
};
|
};
|
||||||
}, [sessionId, packetId]);
|
}, [sessionId, packetId, screen]);
|
||||||
|
|
||||||
return presence;
|
return presence;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The other people on `packetId`, from the presence list. One entry per user, sorted by name. */
|
/** The other people on `packetId`, from the presence list. One entry per user (one with a role first), sorted by name. */
|
||||||
export function othersOn(users: readonly PacketPresenceUser[], packetId: number, myId: string | null | undefined): PacketPresenceUser[] {
|
export function othersOn(users: readonly PacketPresenceUser[], packetId: number, myId: string | null | undefined): PacketPresenceUser[] {
|
||||||
const seen = new Set<string>();
|
const byUser = new Map<string, PacketPresenceUser>();
|
||||||
const out: PacketPresenceUser[] = [];
|
|
||||||
for (const u of users) {
|
for (const u of users) {
|
||||||
if (u.packet_id === null || u.packet_id === undefined || Number(u.packet_id) !== packetId) continue;
|
if (u.packet_id === null || u.packet_id === undefined || Number(u.packet_id) !== packetId) continue;
|
||||||
if (myId && u.user_id === myId) continue;
|
if (myId && u.user_id === myId) continue;
|
||||||
if (seen.has(u.user_id)) continue;
|
const had = byUser.get(u.user_id);
|
||||||
seen.add(u.user_id);
|
if (!had || (!presenceRole(had) && presenceRole(u))) byUser.set(u.user_id, u);
|
||||||
out.push(u);
|
|
||||||
}
|
}
|
||||||
return out.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
return [...byUser.values()].sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The role shown next to a name: the server's role label, else the label of the screen the person has open. */
|
||||||
|
export function presenceRole(u: Pick<PresenceUser, "role" | "screen">): string {
|
||||||
|
if (u.role) return u.role;
|
||||||
|
return u.screen && isScreen(u.screen) ? SCREEN_LABELS[u.screen] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Also in this packet now: Andy Welder (Weld Stall) · Wes Super (Supervisor)". Empty when alone. */
|
||||||
|
export function presenceText(others: readonly PresenceUser[]): string {
|
||||||
|
if (!others.length) return "";
|
||||||
|
const names = others.map((u) => {
|
||||||
|
const r = presenceRole(u);
|
||||||
|
return r ? `${u.display_name} (${r})` : u.display_name;
|
||||||
|
});
|
||||||
|
return `Also in this packet now: ${names.join(" · ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useMemo, useRef, type ReactNode } from "react";
|
||||||
import type { PacketCard } from "../api/types";
|
import type { PacketCard } from "../api/types";
|
||||||
|
import { sigMap, useFlip } from "../lib/useFlip";
|
||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
|
|
||||||
export interface ColumnDef {
|
export interface ColumnDef {
|
||||||
@@ -10,8 +11,17 @@ export interface ColumnDef {
|
|||||||
empty: ReactNode;
|
empty: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A card in a column, wrapped so motion can find and move it by data-card-id. */
|
||||||
|
export function CardSlot({ id, children }: { id: number; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="cardw" data-card-id={id}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The old column(): a titled column of cards with a count, or the empty note. */
|
/** The old column(): a titled column of cards with a count, or the empty note. */
|
||||||
export function Column({ title, cards, empty }: Omit<ColumnDef, "key">) {
|
export function Column({ title, cards, empty, render }: Omit<ColumnDef, "key"> & { render?: (p: PacketCard) => ReactNode }) {
|
||||||
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||||
return (
|
return (
|
||||||
<section className="col" aria-labelledby={id}>
|
<section className="col" aria-labelledby={id}>
|
||||||
@@ -19,17 +29,28 @@ export function Column({ title, cards, empty }: Omit<ColumnDef, "key">) {
|
|||||||
{title}
|
{title}
|
||||||
<span className="c">{cards.length}</span>
|
<span className="c">{cards.length}</span>
|
||||||
</h3>
|
</h3>
|
||||||
{cards.length ? cards.map((p) => <Card key={p.id} p={p} />) : <div className="empty">{empty}</div>}
|
{cards.length ? (
|
||||||
|
cards.map((p) => (
|
||||||
|
<CardSlot key={p.id} id={p.id}>
|
||||||
|
{render ? render(p) : <Card p={p} />}
|
||||||
|
</CardSlot>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="empty">{empty}</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A row of columns. */
|
/** A row of columns. Cards glide to their new column, new ones fade in, and ones changed elsewhere glow. */
|
||||||
export function Board({ columns }: { columns: ColumnDef[] }) {
|
export function Board({ columns, render }: { columns: ColumnDef[]; render?: (p: PacketCard, col: string) => ReactNode }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const sigs = useMemo(() => sigMap(columns.flatMap((c) => c.cards)), [columns]);
|
||||||
|
useFlip(ref, "[data-card-id]", [columns], sigs);
|
||||||
return (
|
return (
|
||||||
<div className="board">
|
<div className="board" ref={ref}>
|
||||||
{columns.map((c) => (
|
{columns.map((c) => (
|
||||||
<Column key={c.key} title={c.title} cards={c.cards} empty={c.empty} />
|
<Column key={c.key} title={c.title} cards={c.cards} empty={c.empty} {...(render ? { render: (p: PacketCard) => render(p, c.key) } : {})} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Facets } from "../api/types";
|
||||||
|
import type { Screen } from "../auth/roles";
|
||||||
|
import type { Department } from "../auth/types";
|
||||||
|
import { FLT_DEFAULT, type Filters } from "../lib/filters";
|
||||||
|
import { mockFetch } from "../test/fetchMock";
|
||||||
|
import { FindBar } from "./FindBar";
|
||||||
|
import type { UnitPick } from "./PickUnit";
|
||||||
|
|
||||||
|
const DEPTS: Department[] = [
|
||||||
|
{ code: "CL", slug: "Clerical", name: "Clerical", kind: "support", stations: [] },
|
||||||
|
{ code: "03", slug: "WeldParts", name: "Weld Parts", kind: "shop", stations: [] },
|
||||||
|
];
|
||||||
|
const FACETS: Facets = {
|
||||||
|
stations: [
|
||||||
|
{ id: 1, name: "Stall 1", dept_code: "03" },
|
||||||
|
{ id: 2, name: "Stall 1", dept_code: "14" },
|
||||||
|
],
|
||||||
|
workers: [{ id: "u-andy", display_name: "Andy Welder" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The find bar with its own state, as a screen holds it. */
|
||||||
|
function Harness({ screen: scr, initial = {}, facets = FACETS, onView }: { screen: Screen; initial?: Partial<Filters>; facets?: Facets | null; onView?: boolean }) {
|
||||||
|
const [f, setF] = useState<Filters>({ ...FLT_DEFAULT, ...initial });
|
||||||
|
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
|
||||||
|
const [view, setView] = useState<"board" | "list">("board");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FindBar
|
||||||
|
f={f}
|
||||||
|
onChange={(p) => setF((x) => ({ ...x, ...p }))}
|
||||||
|
onClear={() => setF({ ...FLT_DEFAULT })}
|
||||||
|
pick={pick}
|
||||||
|
onPick={setPick}
|
||||||
|
view={view}
|
||||||
|
{...(onView === false ? {} : { onView: setView })}
|
||||||
|
count={3}
|
||||||
|
total={10}
|
||||||
|
facets={facets}
|
||||||
|
depts={DEPTS}
|
||||||
|
screen={scr}
|
||||||
|
/>
|
||||||
|
<output data-testid="state">{JSON.stringify(f)}</output>
|
||||||
|
<output data-testid="view">{view}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = () => JSON.parse(screen.getByTestId("state").textContent ?? "{}") as Filters;
|
||||||
|
|
||||||
|
describe("FindBar", () => {
|
||||||
|
it("Esc clears the search and keeps the focus", () => {
|
||||||
|
mockFetch({ "/api/projects": { body: [] } });
|
||||||
|
render(<Harness screen="clerical" />);
|
||||||
|
const box = screen.getByLabelText("Find a job");
|
||||||
|
expect(box).toHaveAttribute("type", "search");
|
||||||
|
expect(box).toHaveAttribute("placeholder", "Job, lot, drawing, WO, heat no., welder, customer, PO, ECN…");
|
||||||
|
box.focus();
|
||||||
|
fireEvent.change(box, { target: { value: "4471" } });
|
||||||
|
expect(box).toHaveValue("4471");
|
||||||
|
const outer = vi.fn();
|
||||||
|
document.addEventListener("keydown", outer);
|
||||||
|
fireEvent.keyDown(box, { key: "Escape" });
|
||||||
|
document.removeEventListener("keydown", outer);
|
||||||
|
expect(box).toHaveValue("");
|
||||||
|
expect(box).toHaveFocus();
|
||||||
|
expect(outer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the chips toggle quick flags, the count shows, and Clear filters counts what is active", () => {
|
||||||
|
mockFetch({ "/api/projects": { body: [] } });
|
||||||
|
render(<Harness screen="warehouse" />);
|
||||||
|
const chips = screen.getByRole("group", { name: "Quick filters" });
|
||||||
|
expect(within(chips).getAllByRole("button").map((b) => b.textContent)).toEqual([
|
||||||
|
"Change order open",
|
||||||
|
"Shorts open",
|
||||||
|
"Project overdue",
|
||||||
|
"Due within 7 days",
|
||||||
|
"Mine",
|
||||||
|
]);
|
||||||
|
expect(screen.getByTestId("flt-count")).toHaveTextContent("Showing 3 of 10 packets");
|
||||||
|
expect(screen.queryByRole("button", { name: /^Clear filters/ })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(within(chips).getByRole("button", { name: "Shorts open" }));
|
||||||
|
fireEvent.click(within(chips).getByRole("button", { name: "Mine" }));
|
||||||
|
expect(within(chips).getByRole("button", { name: "Shorts open" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(state().flags).toEqual(["short", "mine"]);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Clear filters (2)" }));
|
||||||
|
expect(state().flags).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Filters opens the advanced row; closing it resets the selects", () => {
|
||||||
|
mockFetch({ "/api/projects": { body: [] } });
|
||||||
|
render(<Harness screen="supervisor" />);
|
||||||
|
const btn = screen.getByRole("button", { name: /^Filters/ });
|
||||||
|
expect(btn).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(screen.queryByLabelText("Status")).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(btn).toHaveAttribute("aria-expanded", "true");
|
||||||
|
const dept = screen.getByLabelText("Department");
|
||||||
|
expect(within(dept).getAllByRole("option").map((o) => o.textContent)).toEqual(["All departments", "03 Weld Parts"]);
|
||||||
|
expect(within(screen.getByLabelText("Status")).getAllByRole("option")[0]).toHaveTextContent("Any status");
|
||||||
|
// Two stations share a name: the department tells them apart.
|
||||||
|
expect(within(screen.getByLabelText("Station")).getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||||
|
"Any station",
|
||||||
|
"Stall 1 (03)",
|
||||||
|
"Stall 1 (14)",
|
||||||
|
]);
|
||||||
|
expect(within(screen.getByLabelText("Worker")).getAllByRole("option").map((o) => o.textContent)).toEqual(["Anyone", "Andy Welder"]);
|
||||||
|
expect(screen.queryByLabelText("Flag")).not.toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByLabelText("Status"), { target: { value: "station" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Station"), { target: { value: "2" } });
|
||||||
|
expect(btn).toHaveTextContent("Filters 2");
|
||||||
|
expect(screen.getByRole("button", { name: "Filters 2" })).toBe(btn);
|
||||||
|
expect(state()).toMatchObject({ status: "station", stall: "2" });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(state()).toMatchObject({ status: "all", stall: "all", more: false });
|
||||||
|
expect(screen.queryByLabelText("Status")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the advanced row stays open while a select is set", () => {
|
||||||
|
mockFetch({ "/api/projects": { body: [] } });
|
||||||
|
render(<Harness screen="clerical" initial={{ flag: "rev" }} />);
|
||||||
|
expect(screen.getByRole("button", { name: /^Filters/ })).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(screen.getByLabelText("Flag")).toHaveValue("rev");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the parts each screen does not use", () => {
|
||||||
|
mockFetch({ "/api/projects": { body: [] } });
|
||||||
|
const { unmount } = render(<Harness screen="stall" initial={{ more: true }} />);
|
||||||
|
expect(screen.queryByLabelText("Project")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Department")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Station")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Worker")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Status")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Mine" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "List" })).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<Harness screen="pm" initial={{ more: true }} facets={null} onView={false} />);
|
||||||
|
expect(screen.getByLabelText("Project")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Flag")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Station")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Worker")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Mine" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "List" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Board | List switches the view", () => {
|
||||||
|
mockFetch({ "/api/projects": { body: [] } });
|
||||||
|
render(<Harness screen="qc" />);
|
||||||
|
expect(screen.getByRole("button", { name: "Board" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "List" }));
|
||||||
|
expect(screen.getByTestId("view")).toHaveTextContent("list");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,63 +1,153 @@
|
|||||||
import { FLAG_FILTERS } from "../lib/packets";
|
import type { ReactNode } from "react";
|
||||||
|
import type { Facets } from "../api/types";
|
||||||
|
import type { Department } from "../auth/types";
|
||||||
|
import type { Screen } from "../auth/roles";
|
||||||
|
import { ALL, countActive, countAdvanced, FLT_FLAGKIND, FLT_FLAGS, FLT_STATUS, partsFor, type Filters, type ViewKind } from "../lib/filters";
|
||||||
|
import { dueInfo, projectPillClass, PROJECT_STATUS_LABEL } from "../lib/packets";
|
||||||
import { PickUnit, type UnitPick } from "./PickUnit";
|
import { PickUnit, type UnitPick } from "./PickUnit";
|
||||||
|
|
||||||
export type BoardView = "board" | "list";
|
export type BoardView = ViewKind;
|
||||||
|
|
||||||
interface FindBarProps {
|
export interface FindBarProps {
|
||||||
q: string;
|
f: Filters;
|
||||||
onQ: (q: string) => void;
|
onChange: (patch: Partial<Filters>) => void;
|
||||||
|
onClear: () => void;
|
||||||
pick: UnitPick;
|
pick: UnitPick;
|
||||||
onPick: (v: UnitPick) => void;
|
onPick: (p: UnitPick) => void;
|
||||||
/** The Board | List toggle. Hidden when onView is not given. */
|
/** The Board | List toggle. Hidden when onView is not given (as on PM). */
|
||||||
view?: BoardView;
|
view?: ViewKind;
|
||||||
onView?: (v: BoardView) => void;
|
onView?: (v: ViewKind) => void;
|
||||||
/** "Showing n packets". */
|
/** "Showing n of total packets". Null while loading. */
|
||||||
count: number | null;
|
count: number | null;
|
||||||
/** The flag kind filter. Shown when onFlag is given. */
|
total?: number | null;
|
||||||
flag?: string;
|
/** The Station and Worker choices. Their selects hide without them. */
|
||||||
onFlag?: (v: string) => void;
|
facets: Facets | null;
|
||||||
|
/** The department list. Clerical (CL) is left out here. */
|
||||||
|
depts: readonly Department[];
|
||||||
|
screen: Screen;
|
||||||
|
/** The project summary line. Without it a short line for the picked project shows. */
|
||||||
|
summary?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find a job: the search box, the project then unit picker, and the Board | List toggle. */
|
const ADV_RESET: Partial<Filters> = { more: false, dept: ALL, status: ALL, stall: ALL, who: ALL, flag: ALL };
|
||||||
export function FindBar({ q, onQ, pick, onPick, view = "board", onView, count, flag = "", onFlag }: FindBarProps) {
|
|
||||||
|
function Select({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
allLabel,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
allLabel: string;
|
||||||
|
options: ReadonlyArray<readonly [string, string]>;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const known = value === ALL || options.some(([v]) => v === value);
|
||||||
return (
|
return (
|
||||||
<section className="fltbar" aria-label="Find jobs">
|
|
||||||
<div className="fltrow">
|
|
||||||
<label className="f fltq">
|
|
||||||
<span>Find a job</span>
|
|
||||||
<input
|
|
||||||
className="in"
|
|
||||||
type="search"
|
|
||||||
placeholder="Project, unit, lot, drawing, WO, customer, PO, title"
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
value={q}
|
|
||||||
onChange={(e) => onQ(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape" && q) {
|
|
||||||
e.stopPropagation();
|
|
||||||
onQ("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="f fltp">
|
|
||||||
<PickUnit value={pick} onChange={onPick} />
|
|
||||||
</div>
|
|
||||||
{onFlag ? (
|
|
||||||
<label className="f">
|
<label className="f">
|
||||||
<span>Flags</span>
|
<span>{label}</span>
|
||||||
<select className="in" value={flag} onChange={(e) => onFlag(e.target.value)}>
|
<select className="in" aria-label={label} value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
{FLAG_FILTERS.map(([v, l]) => (
|
<option value={ALL}>{allLabel}</option>
|
||||||
|
{known ? null : <option value={value}>{value}</option>}
|
||||||
|
{options.map(([v, l]) => (
|
||||||
<option key={v} value={v}>
|
<option key={v} value={v}>
|
||||||
{l}
|
{l}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old fltProjSummary, short: the picked project or unit, customer, PO and due date. */
|
||||||
|
function PickSummary({ pick }: { pick: UnitPick }) {
|
||||||
|
const p = pick.project;
|
||||||
|
if (!p) return null;
|
||||||
|
const di = dueInfo(pick.unit?.due_effective ?? p.due ?? null);
|
||||||
|
const bits = [p.customer ?? "", p.po ? `PO ${p.po}` : ""].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<div className="projsum" data-testid="projsum">
|
||||||
|
<div>
|
||||||
|
{pick.unit ? <span className="muted">{p.number} › </span> : null}
|
||||||
|
<b className="mono">{pick.unit ? pick.unit.number : p.number}</b> {pick.unit ? (pick.unit.name ?? "") : (p.name ?? "")}{" "}
|
||||||
|
{p.status ? <span className={projectPillClass(p.status)}>{PROJECT_STATUS_LABEL[p.status]}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="small muted">
|
||||||
|
{bits.length ? `${bits.join(" · ")} · ` : ""}
|
||||||
|
<span className={di.cls}>{di.txt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a job (the old fltBar). Row one: the search box, the project then unit picker, Filters and Board | List.
|
||||||
|
* Row two, while Filters is open or any of its selects is set: Department, Status, Station, Worker, Flag.
|
||||||
|
* Then the project summary and the chips row with the count and Clear filters.
|
||||||
|
*/
|
||||||
|
export function FindBar({ f, onChange, onClear, pick, onPick, view = "board", onView, count, total, facets, depts, screen, summary }: FindBarProps) {
|
||||||
|
const parts = partsFor(screen);
|
||||||
|
const adv = countAdvanced(f, screen);
|
||||||
|
const n = countActive(f, parts.project && !!pick.project, screen);
|
||||||
|
const open = f.more || adv > 0;
|
||||||
|
|
||||||
|
const deptOpts = depts.filter((d) => d.code !== "CL").map((d) => [d.code, `${d.code} ${d.name}`] as const);
|
||||||
|
const stations = facets?.stations ?? [];
|
||||||
|
const dupNames = new Set(stations.map((s) => s.name).filter((nm, i, a) => a.indexOf(nm) !== i));
|
||||||
|
const stationOpts = stations.map((s) => [String(s.id), dupNames.has(s.name) ? `${s.name} (${s.dept_code})` : s.name] as const);
|
||||||
|
const workerOpts = (facets?.workers ?? []).map((w) => [w.id, w.display_name] as const);
|
||||||
|
const chips = FLT_FLAGS.filter(([k]) => k !== "mine" || parts.mine);
|
||||||
|
const showStations = parts.stall && (stationOpts.length > 0 || f.stall !== ALL);
|
||||||
|
const showWorkers = parts.who && (workerOpts.length > 0 || f.who !== ALL);
|
||||||
|
|
||||||
|
const toggleFlag = (k: string) => onChange({ flags: f.flags.includes(k) ? f.flags.filter((x) => x !== k) : [...f.flags, k] });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="fltbar" aria-label="Find jobs" data-testid="findbar">
|
||||||
|
<div className="fltrow">
|
||||||
|
<label className="f fltq">
|
||||||
|
<span>Find a job</span>
|
||||||
|
<input
|
||||||
|
className="in"
|
||||||
|
type="search"
|
||||||
|
placeholder="Job, lot, drawing, WO, heat no., welder, customer, PO, ECN…"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={f.q}
|
||||||
|
onChange={(e) => onChange({ q: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape" && f.q) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange({ q: "" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{parts.project ? (
|
||||||
|
<div className="f fltp">
|
||||||
|
<PickUnit value={pick} onChange={onPick} />
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{onView ? (
|
|
||||||
<div className="fltbtns">
|
<div className="fltbtns">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={open ? "btn on" : "btn"}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => onChange(open ? ADV_RESET : { more: true })}
|
||||||
|
>
|
||||||
|
Filters
|
||||||
|
{adv ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<b className="fltn">{adv}</b>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<span aria-hidden="true">{open ? " ▴" : " ▾"}</span>
|
||||||
|
</button>
|
||||||
|
{onView && parts.view ? (
|
||||||
<div className="seg" role="group" aria-label="View">
|
<div className="seg" role="group" aria-label="View">
|
||||||
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
|
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
|
||||||
Board
|
Board
|
||||||
@@ -66,17 +156,40 @@ export function FindBar({ q, onQ, pick, onPick, view = "board", onView, count, f
|
|||||||
List
|
List
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{count !== null ? (
|
</div>
|
||||||
|
{open ? (
|
||||||
|
<div className="fltrow fltadv" data-testid="fltadv">
|
||||||
|
{parts.dept ? <Select label="Department" value={f.dept} allLabel="All departments" options={deptOpts} onChange={(v) => onChange({ dept: v })} /> : null}
|
||||||
|
<Select label="Status" value={f.status} allLabel="Any status" options={FLT_STATUS} onChange={(v) => onChange({ status: v })} />
|
||||||
|
{showStations ? <Select label="Station" value={f.stall} allLabel="Any station" options={stationOpts} onChange={(v) => onChange({ stall: v })} /> : null}
|
||||||
|
{showWorkers ? <Select label="Worker" value={f.who} allLabel="Anyone" options={workerOpts} onChange={(v) => onChange({ who: v })} /> : null}
|
||||||
|
{parts.flag ? <Select label="Flag" value={f.flag} allLabel="Any" options={FLT_FLAGKIND} onChange={(v) => onChange({ flag: v })} /> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{summary ?? (parts.project ? <PickSummary pick={pick} /> : null)}
|
||||||
<div className="fltrow2">
|
<div className="fltrow2">
|
||||||
<span className="grow" />
|
<div className="chips" role="group" aria-label="Quick filters">
|
||||||
<span className="small muted">
|
{chips.map(([k, l]) => (
|
||||||
Showing <b>{count}</b> packet{count === 1 ? "" : "s"}
|
<button key={k} type="button" className="chip" aria-pressed={f.flags.includes(k)} onClick={() => toggleFlag(k)}>
|
||||||
</span>
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<span className="grow" />
|
||||||
|
{count !== null ? (
|
||||||
|
<span className="small muted" data-testid="flt-count">
|
||||||
|
Showing <b>{count}</b>
|
||||||
|
{total !== undefined && total !== null ? ` of ${total}` : ""} packet{(total ?? count) === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{n ? (
|
||||||
|
<button type="button" className="btn sm ghost" onClick={onClear}>
|
||||||
|
Clear filters ({n})
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { SortKey } from "../lib/filters";
|
||||||
|
import { makeCard } from "../test/packets";
|
||||||
|
import { JobList } from "./JobList";
|
||||||
|
|
||||||
|
const ROWS = [
|
||||||
|
makeCard({ id: 1, updated_at: "2026-10-10T00:00:00Z", project: { id: 1, number: "L10", name: "Ten" }, unit: { id: 1, number: "L10001" } }),
|
||||||
|
makeCard({
|
||||||
|
id: 2,
|
||||||
|
updated_at: "2026-10-12T00:00:00Z",
|
||||||
|
project: { id: 2, number: "L9", name: "Nine" },
|
||||||
|
unit: { id: 2, number: "L9001" },
|
||||||
|
status: "in_progress",
|
||||||
|
station: { id: 4, name: "Stall 1" },
|
||||||
|
welder: { id: "u", display_name: "Andy Welder", stamp: "75" },
|
||||||
|
open_shorts: 2,
|
||||||
|
open_cos: [3],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
function Harness({ onSort, serverSorted = false }: { onSort?: (k: SortKey) => void; serverSorted?: boolean }) {
|
||||||
|
const [sort, setSort] = useState<SortKey>("updated");
|
||||||
|
return (
|
||||||
|
<MemoryRouter initialEntries={["/supervisor"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/supervisor"
|
||||||
|
element={
|
||||||
|
<JobList
|
||||||
|
rows={ROWS}
|
||||||
|
sort={sort}
|
||||||
|
serverSorted={serverSorted}
|
||||||
|
onSort={(k) => {
|
||||||
|
setSort(k);
|
||||||
|
onSort?.(k);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/packets/:id" element={<h1>Packet page</h1>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = () =>
|
||||||
|
within(screen.getByRole("table", { name: "Jobs" }))
|
||||||
|
.getAllByRole("row")
|
||||||
|
.slice(1)
|
||||||
|
.map((r) => within(r).getAllByRole("cell")[0]?.querySelector("b")?.textContent);
|
||||||
|
|
||||||
|
describe("JobList", () => {
|
||||||
|
it("has the old columns and sorts by the select", () => {
|
||||||
|
const onSort = vi.fn();
|
||||||
|
render(<Harness onSort={onSort} />);
|
||||||
|
expect(screen.getByRole("heading", { name: "Jobs" })).toBeInTheDocument();
|
||||||
|
const heads = within(screen.getByRole("table", { name: "Jobs" }))
|
||||||
|
.getAllByRole("columnheader")
|
||||||
|
.map((h) => h.textContent);
|
||||||
|
expect(heads).toEqual(["Job", "Operation", "Department", "Status", "Station / worker", "Drawing", "Project due", "Updated", "Flags"]);
|
||||||
|
expect(jobs()).toEqual(["L9001 · WELD-PART", "L10001 · WELD-PART"]);
|
||||||
|
const sel = screen.getByLabelText("Sort by");
|
||||||
|
expect(within(sel).getAllByRole("option").map((o) => o.textContent)).toEqual(["Last updated", "Project / lot", "Due date", "Status", "Department"]);
|
||||||
|
fireEvent.change(sel, { target: { value: "status" } });
|
||||||
|
expect(onSort).toHaveBeenCalledWith("status");
|
||||||
|
expect(jobs()).toEqual(["L10001 · WELD-PART", "L9001 · WELD-PART"]);
|
||||||
|
const row = screen.getByText("L9001 · WELD-PART").closest("tr") as HTMLElement;
|
||||||
|
expect(row).toHaveTextContent("Andy Welder");
|
||||||
|
expect(row).toHaveTextContent("2 short");
|
||||||
|
expect(row).toHaveTextContent("CO 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the server's order when told the rows are sorted there", () => {
|
||||||
|
render(<Harness serverSorted />);
|
||||||
|
expect(jobs()).toEqual(["L10001 · WELD-PART", "L9001 · WELD-PART"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter on a row opens the packet", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
const row = screen.getByText("L10001 · WELD-PART").closest("tr") as HTMLElement;
|
||||||
|
expect(row).toHaveAttribute("tabindex", "0");
|
||||||
|
fireEvent.keyDown(row, { key: "Enter" });
|
||||||
|
expect(screen.getByRole("heading", { name: "Packet page" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the empty row", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<JobList rows={[]} sort="updated" onSort={() => undefined} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("No packets match these filters.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useId, useMemo, type KeyboardEvent, type ReactNode } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import type { PacketCard } from "../api/types";
|
||||||
|
import { fmtTime } from "../lib/format";
|
||||||
|
import { FLT_SORT, sorter, type SortKey } from "../lib/filters";
|
||||||
|
import { dueInfo, flagClass, jobName, openCoCount, pillClass, statusLabel } from "../lib/packets";
|
||||||
|
import { pillClassFor, pillText, whStage } from "../warehouse/stages";
|
||||||
|
|
||||||
|
export interface JobListProps {
|
||||||
|
rows: readonly PacketCard[];
|
||||||
|
sort: SortKey;
|
||||||
|
onSort: (k: SortKey) => void;
|
||||||
|
/** The rows already come in the server's sort= order. Otherwise they are sorted here the same way. */
|
||||||
|
serverSorted?: boolean;
|
||||||
|
/** The section heading. */
|
||||||
|
title?: string;
|
||||||
|
/** Buttons at the right of the heading, like Excel (CSV) on PM. */
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status pill; a warehouse packet shows its stage, as on the cards. */
|
||||||
|
function Pill({ p }: { p: PacketCard }) {
|
||||||
|
if (p.status === "warehouse" && p.wh_stage !== undefined) {
|
||||||
|
const s = whStage(p);
|
||||||
|
return <span className={pillClassFor(s)}>{pillText(s)}</span>;
|
||||||
|
}
|
||||||
|
return <span className={pillClass(p.status)}>{statusLabel(p.status)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The list view (the old fltListView): one row per packet. Enter or a click opens it. */
|
||||||
|
export function JobList({ rows, sort, onSort, serverSorted = false, title = "Jobs", actions }: JobListProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const id = useId();
|
||||||
|
const sorted = useMemo(() => (serverSorted ? [...rows] : [...rows].sort(sorter(sort))), [rows, sort, serverSorted]);
|
||||||
|
const open = (pid: number) => navigate(`/packets/${pid}`);
|
||||||
|
const onKey = (e: KeyboardEvent, pid: number) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
open(pid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<section className="sec joblist-sec" aria-labelledby={id} data-testid="joblist">
|
||||||
|
<div className="sec-h">
|
||||||
|
<h2 id={id}>{title}</h2>
|
||||||
|
<span className="ct">
|
||||||
|
{sorted.length} packet{sorted.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<span className="grow" />
|
||||||
|
{actions}
|
||||||
|
<label className="f jlsort">
|
||||||
|
<span>Sort by</span>
|
||||||
|
<select className="in" aria-label="Sort by" value={sort} onChange={(e) => onSort(e.target.value as SortKey)}>
|
||||||
|
{FLT_SORT.map(([k, l]) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="tw">
|
||||||
|
<table className="joblist" aria-label={title}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Job</th>
|
||||||
|
<th>Operation</th>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Station / worker</th>
|
||||||
|
<th>Drawing</th>
|
||||||
|
<th>Project due</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((p) => {
|
||||||
|
const di = dueInfo(p.due_effective);
|
||||||
|
const cos = openCoCount(p);
|
||||||
|
return (
|
||||||
|
<tr key={p.id} className="rowlink" tabIndex={0} data-card-id={p.id} onClick={() => open(p.id)} onKeyDown={(e) => onKey(e, p.id)}>
|
||||||
|
<td>
|
||||||
|
<b>{jobName(p)}</b>
|
||||||
|
<div className="sub">{p.project.name ?? ""}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{p.op ?? ""}
|
||||||
|
<div className="sub">{p.title ?? ""}</div>
|
||||||
|
</td>
|
||||||
|
<td className="small">
|
||||||
|
{p.dept.code} {p.dept.name}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Pill p={p} />
|
||||||
|
</td>
|
||||||
|
<td className="small">
|
||||||
|
{p.station?.name ?? "-"}
|
||||||
|
{p.welder ? <div>{p.welder.display_name}</div> : null}
|
||||||
|
</td>
|
||||||
|
<td className="mono small">
|
||||||
|
{p.drawing ?? ""} {p.rev ? `R${p.rev}` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="small">
|
||||||
|
<span className={di.cls}>{di.txt.replace(/^Due /, "")}</span>
|
||||||
|
</td>
|
||||||
|
<td className="small mono">{fmtTime(p.updated_at)}</td>
|
||||||
|
<td>
|
||||||
|
{cos ? <span className="pill st-review">CO {cos}</span> : null}{" "}
|
||||||
|
{p.open_shorts ? <span className="pill st-review">{p.open_shorts} short</span> : null}
|
||||||
|
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="muted">
|
||||||
|
No packets match these filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { memoryStorage } from "../screens/supervisor/memoryStorage";
|
||||||
|
import { Kpis } from "./Kpis";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("localStorage", memoryStorage());
|
||||||
|
vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener: () => undefined, removeEventListener: () => undefined }));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Kpis", () => {
|
||||||
|
it("a tile with a filter is a button; a tap calls onFilter, aria-pressed follows on", () => {
|
||||||
|
const onFilter = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<Kpis
|
||||||
|
items={[
|
||||||
|
{ n: 3, label: "Ready to assign", alert: true, filter: { key: "status", value: "ready" }, on: false },
|
||||||
|
{ n: 0, label: "Station hours" },
|
||||||
|
]}
|
||||||
|
onFilter={onFilter}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const tile = screen.getByRole("button", { name: "Ready to assign: 3" });
|
||||||
|
expect(tile).toHaveClass("kpi", "hot");
|
||||||
|
expect(tile).toHaveAttribute("aria-pressed", "false");
|
||||||
|
expect(tile).toHaveAttribute("title", "Show only these below");
|
||||||
|
expect(screen.queryByRole("button", { name: /Station hours/ })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Station hours: 0")).toHaveClass("zero");
|
||||||
|
fireEvent.click(tile);
|
||||||
|
expect(onFilter).toHaveBeenCalledWith({ key: "status", value: "ready" });
|
||||||
|
|
||||||
|
rerender(<Kpis items={[{ n: 3, label: "Ready to assign", filter: { key: "status", value: "ready" }, on: true }]} onFilter={onFilter} />);
|
||||||
|
const on = screen.getByRole("button", { name: "Ready to assign: 3" });
|
||||||
|
expect(on).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(on).toHaveClass("on");
|
||||||
|
expect(on).toHaveAttribute("title", "Show everything again");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without onFilter the tiles stay plain", () => {
|
||||||
|
render(<Kpis items={[{ n: 2, label: "Waiting", filter: { key: "status", value: "qc" } }]} />);
|
||||||
|
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Waiting: 2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts up to a new number under motion and bumps the tile", () => {
|
||||||
|
let now = 0;
|
||||||
|
const frames: FrameRequestCallback[] = [];
|
||||||
|
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
|
||||||
|
vi.stubGlobal("cancelAnimationFrame", () => undefined);
|
||||||
|
const { rerender } = render(<Kpis items={[{ n: 10, label: "Waiting" }]} />);
|
||||||
|
rerender(<Kpis items={[{ n: 20, label: "Waiting" }]} />);
|
||||||
|
const tile = screen.getByLabelText("Waiting: 20");
|
||||||
|
expect(tile).toHaveClass("m-bump");
|
||||||
|
expect(tile.querySelector(".n")).toHaveTextContent("10");
|
||||||
|
now = 210;
|
||||||
|
act(() => frames.shift()?.(now));
|
||||||
|
const mid = Number(tile.querySelector(".n")?.textContent);
|
||||||
|
expect(mid).toBeGreaterThan(10);
|
||||||
|
expect(mid).toBeLessThan(20);
|
||||||
|
now = 500;
|
||||||
|
act(() => frames.shift()?.(now));
|
||||||
|
expect(tile.querySelector(".n")).toHaveTextContent("20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count with motion off", () => {
|
||||||
|
window.localStorage.setItem("ps.motion", "off");
|
||||||
|
const { rerender } = render(<Kpis items={[{ n: 10, label: "Waiting" }]} />);
|
||||||
|
rerender(<Kpis items={[{ n: 20, label: "Waiting" }]} />);
|
||||||
|
const tile = screen.getByLabelText("Waiting: 20");
|
||||||
|
expect(tile.querySelector(".n")).toHaveTextContent("20");
|
||||||
|
expect(tile).not.toHaveClass("m-bump");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,26 +1,113 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import type { KpiKey } from "../lib/filters";
|
||||||
|
import { BUMP_MS, cancelFrame, COUNT_MS, countStep, nextFrame, useMotion } from "../lib/motion";
|
||||||
|
|
||||||
|
export interface KpiFilter {
|
||||||
|
key: KpiKey;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Kpi {
|
export interface Kpi {
|
||||||
n: ReactNode;
|
n: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
/** Turns pink when above zero. */
|
/** Turns pink when above zero. */
|
||||||
alert?: boolean;
|
alert?: boolean;
|
||||||
|
/** Tapping the tile filters the boards below. */
|
||||||
|
filter?: KpiFilter;
|
||||||
|
/** The tile's filter is on. */
|
||||||
|
on?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The old kpis(): number tiles. Zero is grey, an alert above zero is pink. */
|
export interface KpisProps {
|
||||||
export function Kpis({ items, label = "Numbers" }: { items: Kpi[]; label?: string }) {
|
items: Kpi[];
|
||||||
return (
|
label?: string;
|
||||||
<div className="kpis" role="group" aria-label={label}>
|
/** With this, tiles that carry a filter are buttons. */
|
||||||
{items.map((k) => {
|
onFilter?: (f: KpiFilter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A whole number counts from its last value to the new one over 420 ms, under motion. Anything else shows as is. */
|
||||||
|
function Num({ n, motion, onBump }: { n: ReactNode; motion: boolean; onBump: () => void }) {
|
||||||
|
const whole = typeof n === "number" && Number.isInteger(n) ? n : typeof n === "string" && /^\d+$/.test(n.trim()) ? Number(n) : null;
|
||||||
|
const prev = useRef<number | null>(whole);
|
||||||
|
const [shown, setShown] = useState<number | null>(null);
|
||||||
|
const bump = useRef(onBump);
|
||||||
|
useEffect(() => {
|
||||||
|
bump.current = onBump;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const from = prev.current;
|
||||||
|
prev.current = whole;
|
||||||
|
if (whole === null || from === null || from === whole || !motion) {
|
||||||
|
setShown(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bump.current();
|
||||||
|
const t0 = performance.now();
|
||||||
|
let raf = 0;
|
||||||
|
const step = (t: number) => {
|
||||||
|
const f = (t - t0) / COUNT_MS;
|
||||||
|
if (f >= 1) {
|
||||||
|
setShown(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShown(countStep(from, whole, f));
|
||||||
|
raf = nextFrame(step);
|
||||||
|
};
|
||||||
|
setShown(from);
|
||||||
|
raf = nextFrame(step);
|
||||||
|
return () => cancelFrame(raf);
|
||||||
|
}, [whole, motion]);
|
||||||
|
|
||||||
|
return <div className="n">{shown !== null ? shown : n}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({ k, motion, onFilter }: { k: Kpi; motion: boolean; onFilter: KpisProps["onFilter"] }) {
|
||||||
|
const [bumped, setBumped] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bumped) return;
|
||||||
|
const t = window.setTimeout(() => setBumped(false), BUMP_MS);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [bumped]);
|
||||||
const zero = String(k.n) === "0";
|
const zero = String(k.n) === "0";
|
||||||
const cls = ["kpi", k.alert && !zero ? "hot" : "", zero ? "zero" : ""].filter(Boolean).join(" ");
|
const tap = k.filter && onFilter ? k.filter : null;
|
||||||
return (
|
const cls = ["kpi", k.alert && !zero ? "hot" : "", zero ? "zero" : "", tap && k.on ? "on" : "", bumped ? "m-bump" : ""].filter(Boolean).join(" ");
|
||||||
<div key={k.label} className={cls} aria-label={`${k.label}: ${String(k.n)}`}>
|
const inner = (
|
||||||
<div className="n">{k.n}</div>
|
<>
|
||||||
|
<Num n={k.n} motion={motion} onBump={() => setBumped(true)} />
|
||||||
<div className="l">{k.label}</div>
|
<div className="l">{k.label}</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
})}
|
const name = `${k.label}: ${String(k.n)}`;
|
||||||
|
if (tap && onFilter) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cls}
|
||||||
|
aria-label={name}
|
||||||
|
aria-pressed={!!k.on}
|
||||||
|
title={k.on ? "Show everything again" : "Show only these below"}
|
||||||
|
onClick={() => onFilter(tap)}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={cls} aria-label={name}>
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old kpis(): number tiles. Zero is grey, an alert above zero is pink. A tile with a filter is a button. */
|
||||||
|
export function Kpis({ items, label = "Numbers", onFilter }: KpisProps) {
|
||||||
|
const motion = useMotion();
|
||||||
|
return (
|
||||||
|
<div className="kpis" role="group" aria-label={label}>
|
||||||
|
{items.map((k) => (
|
||||||
|
<Tile key={k.label} k={k} motion={motion} onFilter={onFilter} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import { Link, Outlet, useLocation } from "react-router";
|
import { Link, Outlet, useLocation } from "react-router";
|
||||||
import { SCREEN_LABELS, firstScreen, isScreen, orderScreens, type Screen } from "../auth/roles";
|
import { SCREEN_LABELS, firstScreen, isScreen, orderScreens, type Screen } from "../auth/roles";
|
||||||
import { useAuth } from "../auth/useAuth";
|
import { useAuth } from "../auth/useAuth";
|
||||||
|
import { replayClass } from "../lib/motion";
|
||||||
|
import { useScrollRestore } from "../lib/scrollRestore";
|
||||||
import { LockScreen } from "./LockScreen";
|
import { LockScreen } from "./LockScreen";
|
||||||
import { RoleSwitcher } from "./RoleSwitcher";
|
import { RoleSwitcher } from "./RoleSwitcher";
|
||||||
import { TopBar } from "./TopBar";
|
import { TopBar } from "./TopBar";
|
||||||
@@ -25,6 +27,18 @@ export function Layout() {
|
|||||||
const active = isScreen(seg) && screens.includes(seg) ? seg : null;
|
const active = isScreen(seg) && screens.includes(seg) ? seg : null;
|
||||||
const shown = active ?? firstScreen(screens);
|
const shown = active ?? firstScreen(screens);
|
||||||
|
|
||||||
|
// Back lands on the same spot; a new page starts at the top.
|
||||||
|
useScrollRestore();
|
||||||
|
|
||||||
|
// A new screen eases in (the old m-enter), only while body.motion is set; the CSS keys off both.
|
||||||
|
const lastPath = useRef<string | null>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const was = lastPath.current;
|
||||||
|
lastPath.current = pathname;
|
||||||
|
if (was === null || was === pathname) return;
|
||||||
|
return replayClass(mainRef.current, "m-enter", 700);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
// Keep the page mounted behind the lock, but out of reach of the keyboard and screen readers.
|
// Keep the page mounted behind the lock, but out of reach of the keyboard and screen readers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mainRef.current?.toggleAttribute("inert", locked);
|
mainRef.current?.toggleAttribute("inert", locked);
|
||||||
|
|||||||
@@ -1,6 +1,40 @@
|
|||||||
import type { ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import logo from "../assets/logo.png";
|
import logo from "../assets/logo.png";
|
||||||
|
import { setMotion, useMotion } from "../lib/motion";
|
||||||
|
|
||||||
|
/** The Motion on / Motion off switch (the old motionBtn), with a short toast on toggle. */
|
||||||
|
export function MotionButton() {
|
||||||
|
const on = useMotion();
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return;
|
||||||
|
const t = window.setTimeout(() => setToast(null), 2500);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [toast]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn sm ghost mbtn"
|
||||||
|
type="button"
|
||||||
|
aria-pressed={on}
|
||||||
|
title={on ? "Turn animations off" : "Turn animations on"}
|
||||||
|
onClick={() => {
|
||||||
|
setMotion(!on);
|
||||||
|
setToast(on ? "Motion off" : "Motion on");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mdot" aria-hidden="true" />
|
||||||
|
{on ? "Motion on" : "Motion off"}
|
||||||
|
</button>
|
||||||
|
{toast ? (
|
||||||
|
<div className="toast" role="status" data-testid="motion-toast">
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
/** The role switcher, drawn between the brand and the spacer. */
|
/** The role switcher, drawn between the brand and the spacer. */
|
||||||
@@ -21,6 +55,7 @@ export function TopBar({ nav, children }: TopBarProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
{nav}
|
{nav}
|
||||||
|
<MotionButton />
|
||||||
<div className="sp" />
|
<div className="sp" />
|
||||||
{children ? <div className="who">{children}</div> : null}
|
{children ? <div className="who">{children}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { memoryStorage } from "../screens/supervisor/memoryStorage";
|
||||||
|
import { makeCard } from "../test/packets";
|
||||||
|
import {
|
||||||
|
countActive,
|
||||||
|
countAdvanced,
|
||||||
|
FLT_DEFAULT,
|
||||||
|
FLT_FLAGKIND,
|
||||||
|
FLT_FLAGS,
|
||||||
|
FLT_KEY,
|
||||||
|
FLT_SORT,
|
||||||
|
FLT_STATUS,
|
||||||
|
kpiActive,
|
||||||
|
kpiFilter,
|
||||||
|
loadFilters,
|
||||||
|
loadPick,
|
||||||
|
PROJ_KEY,
|
||||||
|
saveFilters,
|
||||||
|
savePick,
|
||||||
|
sorter,
|
||||||
|
toQuery,
|
||||||
|
uniqueCards,
|
||||||
|
useFilters,
|
||||||
|
type Filters,
|
||||||
|
} from "./filters";
|
||||||
|
|
||||||
|
const PROJECT = { id: 7, number: "L999", name: "Spools" };
|
||||||
|
const UNIT = { id: 12, number: "L999002", lot: "002", name: null, due: null, due_effective: null, status: "open" as const, version: 1 };
|
||||||
|
const f = (over: Partial<Filters> = {}): Filters => ({ ...FLT_DEFAULT, ...over });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("localStorage", memoryStorage());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the vocabulary", () => {
|
||||||
|
it("keeps the old labels", () => {
|
||||||
|
expect(FLT_STATUS.map(([v]) => v)).toEqual(["draft", "warehouse", "ready", "shop", "station", "review", "qc", "complete", "filed", "open"]);
|
||||||
|
expect(FLT_FLAGS.map(([, l]) => l)).toEqual(["Change order open", "Shorts open", "Project overdue", "Due within 7 days", "Mine"]);
|
||||||
|
expect(FLT_FLAGKIND.map(([v]) => v)).toEqual(["any", "rev", "unconf", "hand", "none", "vault", "read", "wait", "no"]);
|
||||||
|
expect(FLT_SORT).toEqual([
|
||||||
|
["updated", "Last updated"],
|
||||||
|
["project", "Project / lot"],
|
||||||
|
["due", "Due date"],
|
||||||
|
["status", "Status"],
|
||||||
|
["dept", "Department"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("load and save", () => {
|
||||||
|
it("saves everything but the search text, and loads it back with q empty", () => {
|
||||||
|
saveFilters(f({ q: "heat 4471", dept: "03", flags: ["short"], views: { clerical: "list" }, sort: "due", more: true }));
|
||||||
|
const raw = JSON.parse(window.localStorage.getItem(FLT_KEY) ?? "{}") as Record<string, unknown>;
|
||||||
|
expect(raw).not.toHaveProperty("q");
|
||||||
|
expect(raw).toMatchObject({ dept: "03", flags: ["short"], views: { clerical: "list" }, sort: "due", more: true });
|
||||||
|
expect(loadFilters()).toEqual(f({ dept: "03", flags: ["short"], views: { clerical: "list" }, sort: "due", more: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default on bad or missing values", () => {
|
||||||
|
window.localStorage.setItem(FLT_KEY, JSON.stringify({ q: "x", sort: "nope", flags: ["short", "bogus", 3], views: { qc: "grid", stall: "list" }, dept: 5 }));
|
||||||
|
expect(loadFilters()).toEqual(f({ flags: ["short"], views: { stall: "list" } }));
|
||||||
|
window.localStorage.setItem(FLT_KEY, "{not json");
|
||||||
|
expect(loadFilters()).toEqual(FLT_DEFAULT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the project pick in ps.proj", () => {
|
||||||
|
expect(loadPick()).toEqual({ project: null, unit: null });
|
||||||
|
savePick({ project: PROJECT, unit: UNIT });
|
||||||
|
expect(JSON.parse(window.localStorage.getItem(PROJ_KEY) ?? "null")).toMatchObject({ project_id: 7, unit_id: 12 });
|
||||||
|
expect(loadPick()).toEqual({ project: PROJECT, unit: UNIT });
|
||||||
|
savePick({ project: null, unit: null });
|
||||||
|
expect(window.localStorage.getItem(PROJ_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("counts", () => {
|
||||||
|
it("counts the advanced selects and everything active", () => {
|
||||||
|
const x = f({ q: " weld ", dept: "03", status: "ready", flag: "rev", flags: ["co", "mine"] });
|
||||||
|
expect(countAdvanced(x)).toBe(3);
|
||||||
|
expect(countActive(x, true)).toBe(1 + 3 + 2 + 1);
|
||||||
|
expect(countActive(FLT_DEFAULT, false)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves out the parts a screen hides", () => {
|
||||||
|
const x = f({ dept: "03", stall: "4", who: "u-1", flag: "rev", status: "qc", flags: ["mine", "short"] });
|
||||||
|
expect(countAdvanced(x, "stall")).toBe(1);
|
||||||
|
expect(countActive(x, false, "stall")).toBe(2);
|
||||||
|
expect(countAdvanced(x, "supervisor")).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toQuery", () => {
|
||||||
|
it("sends only what is set, the pick last", () => {
|
||||||
|
expect(toQuery(FLT_DEFAULT, { project: null, unit: null }, { screen: "clerical" })).toEqual({});
|
||||||
|
const q = toQuery(
|
||||||
|
f({ q: " lot 52 ", dept: "03", status: "station", stall: "4", who: "u-1", flag: "rev", flags: ["mine", "co"], sort: "project" }),
|
||||||
|
{ project: PROJECT, unit: UNIT },
|
||||||
|
{ screen: "clerical" },
|
||||||
|
);
|
||||||
|
expect(q).toEqual({
|
||||||
|
q: "lot 52",
|
||||||
|
dept: "03",
|
||||||
|
status: "station",
|
||||||
|
station_id: 4,
|
||||||
|
welder_id: "u-1",
|
||||||
|
flag: "rev",
|
||||||
|
flags: "co,mine",
|
||||||
|
mine_screen: "clerical",
|
||||||
|
sort: "project",
|
||||||
|
unit_id: 12,
|
||||||
|
});
|
||||||
|
expect(toQuery(f(), { project: PROJECT, unit: null }, { screen: "qc" })).toEqual({ project_id: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the stall sends no department, station, worker, flag kind or project, and its Mine names the station", () => {
|
||||||
|
const q = toQuery(f({ dept: "03", stall: "4", who: "u-1", flag: "rev", flags: ["mine", "short"], status: "station" }), { project: PROJECT, unit: null }, {
|
||||||
|
screen: "stall",
|
||||||
|
stationId: 9,
|
||||||
|
});
|
||||||
|
expect(q).toEqual({ status: "station", flags: "short", project_id: 7 });
|
||||||
|
expect(toQuery(f({ flags: ["mine"] }), { project: null, unit: null }, { screen: "warehouse" })).toEqual({ flags: "mine", mine_screen: "warehouse" });
|
||||||
|
expect(toQuery(f({ flag: "rev" }), { project: null, unit: null }, { screen: "supervisor" })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("KPI taps", () => {
|
||||||
|
it("toggles a status and opens the advanced row", () => {
|
||||||
|
const on = { ...FLT_DEFAULT, ...kpiFilter(FLT_DEFAULT, "status", "ready") };
|
||||||
|
expect(on).toMatchObject({ status: "ready", more: true });
|
||||||
|
expect(kpiActive(on, "status", "ready")).toBe(true);
|
||||||
|
const off = { ...on, ...kpiFilter(on, "status", "ready") };
|
||||||
|
expect(off.status).toBe("all");
|
||||||
|
expect(kpiActive(off, "status", "ready")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a quick flag toggles the chip; a flag kind toggles the Flag select", () => {
|
||||||
|
const short = { ...FLT_DEFAULT, ...kpiFilter(FLT_DEFAULT, "flag", "short") };
|
||||||
|
expect(short.flags).toEqual(["short"]);
|
||||||
|
expect(kpiActive(short, "flag", "short")).toBe(true);
|
||||||
|
expect({ ...short, ...kpiFilter(short, "flag", "short") }.flags).toEqual([]);
|
||||||
|
const any = { ...FLT_DEFAULT, ...kpiFilter(FLT_DEFAULT, "flag", "any") };
|
||||||
|
expect(any).toMatchObject({ flag: "any", more: true, flags: [] });
|
||||||
|
expect(kpiActive(any, "flag", "any")).toBe(true);
|
||||||
|
expect({ ...any, ...kpiFilter(any, "flag", "any") }.flag).toBe("all");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useFilters", () => {
|
||||||
|
it("debounces q into the query, saves without q, and keeps the view per screen", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useFilters("supervisor"));
|
||||||
|
expect(result.current.query).toEqual({});
|
||||||
|
act(() => result.current.set({ q: "elbow" }));
|
||||||
|
expect(result.current.query).toEqual({});
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(260);
|
||||||
|
});
|
||||||
|
expect(result.current.query).toEqual({ q: "elbow" });
|
||||||
|
act(() => result.current.setView("list"));
|
||||||
|
expect(result.current.view).toBe("list");
|
||||||
|
const saved = JSON.parse(window.localStorage.getItem(FLT_KEY) ?? "{}") as Record<string, unknown>;
|
||||||
|
expect(saved).toMatchObject({ views: { supervisor: "list" } });
|
||||||
|
expect(JSON.stringify(saved)).not.toContain("elbow");
|
||||||
|
act(() => result.current.set({ q: "" }));
|
||||||
|
expect(result.current.query).toEqual({});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps KPI numbers on the pick only, and Clear keeps the view and the sort", () => {
|
||||||
|
const { result } = renderHook(() => useFilters("clerical"));
|
||||||
|
act(() => result.current.setPick({ project: PROJECT, unit: null }));
|
||||||
|
act(() => result.current.tapKpi("status", "warehouse"));
|
||||||
|
expect(result.current.query).toEqual({ status: "warehouse", project_id: 7 });
|
||||||
|
expect(result.current.scope).toEqual({ project_id: 7 });
|
||||||
|
expect(result.current.isKpiOn("status", "warehouse")).toBe(true);
|
||||||
|
expect(result.current.active).toBe(2);
|
||||||
|
act(() => result.current.set({ sort: "due", views: { clerical: "list" } }));
|
||||||
|
act(() => result.current.clear());
|
||||||
|
expect(result.current.f).toEqual(f({ sort: "due", views: { clerical: "list" } }));
|
||||||
|
expect(result.current.pick).toEqual({ project: null, unit: null });
|
||||||
|
expect(result.current.query).toEqual({ sort: "due" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("list helpers", () => {
|
||||||
|
it("merges columns once each and sorts like the server", () => {
|
||||||
|
const a = makeCard({ id: 1, updated_at: "2026-10-10T00:00:00Z", project: { id: 1, number: "L10", name: null }, unit: { id: 1, number: "L10002" } });
|
||||||
|
const b = makeCard({ id: 2, updated_at: "2026-10-12T00:00:00Z", project: { id: 2, number: "L9", name: null }, unit: { id: 2, number: "L9001" }, status: "qc" });
|
||||||
|
const merged = uniqueCards([[a, b], [b]]);
|
||||||
|
expect(merged.map((p) => p.id)).toEqual([1, 2]);
|
||||||
|
expect([...merged].sort(sorter("updated")).map((p) => p.id)).toEqual([2, 1]);
|
||||||
|
expect([...merged].sort(sorter("project")).map((p) => p.id)).toEqual([2, 1]);
|
||||||
|
expect([...merged].sort(sorter("status")).map((p) => p.id)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
// The find bar's filters on every home screen (the old filters.js), applied by the server.
|
||||||
|
// The filters are saved per browser under ps.flt without the search text. The project pick lives in ps.proj.
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { PacketCard, PacketListQuery, ProjectRef, Unit } from "../api/types";
|
||||||
|
import { STATUSES } from "../api/types";
|
||||||
|
import type { Screen } from "../auth/roles";
|
||||||
|
import type { UnitPick } from "../components/PickUnit";
|
||||||
|
import { nextFrame } from "./motion";
|
||||||
|
|
||||||
|
export type SortKey = "updated" | "project" | "due" | "status" | "dept";
|
||||||
|
export type ViewKind = "board" | "list";
|
||||||
|
|
||||||
|
/** The value of a select that filters nothing. */
|
||||||
|
export const ALL = "all";
|
||||||
|
|
||||||
|
/** Status words the server expands (domain/search.py FLT_STATUS). */
|
||||||
|
export const FLT_STATUS: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
["draft", "Ready to release"],
|
||||||
|
["warehouse", "With warehouse (any stage)"],
|
||||||
|
["ready", "Ready to assign"],
|
||||||
|
["shop", "In the shop (ready to Q.C.)"],
|
||||||
|
["station", "At a station (assigned / working)"],
|
||||||
|
["review", "Supervisor review"],
|
||||||
|
["qc", "At Q.C."],
|
||||||
|
["complete", "Accepted / routed"],
|
||||||
|
["filed", "Filed"],
|
||||||
|
["open", "Everything not filed"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Quick flag chips. They go to the server as flags=co,short,... */
|
||||||
|
export const FLT_FLAGS: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
["co", "Change order open"],
|
||||||
|
["short", "Shorts open"],
|
||||||
|
["overdue", "Project overdue"],
|
||||||
|
["week", "Due within 7 days"],
|
||||||
|
["mine", "Mine"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Why a draft is flagged. Goes to the server as flag=. */
|
||||||
|
export const FLT_FLAGKIND: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
["any", "Flagged (any reason)"],
|
||||||
|
["rev", "Rev mismatch with M-Files"],
|
||||||
|
["unconf", "Rev not confirmed yet"],
|
||||||
|
["hand", "Made or uploaded by hand"],
|
||||||
|
["none", "Still in drafting"],
|
||||||
|
["vault", "Drawing not found in M-Files"],
|
||||||
|
["read", "Drawing couldn't be read"],
|
||||||
|
["wait", "Drawing being looked up"],
|
||||||
|
["no", "Not flagged"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FLT_SORT: ReadonlyArray<readonly [SortKey, string]> = [
|
||||||
|
["updated", "Last updated"],
|
||||||
|
["project", "Project / lot"],
|
||||||
|
["due", "Due date"],
|
||||||
|
["status", "Status"],
|
||||||
|
["dept", "Department"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
/** The search text. Never saved. */
|
||||||
|
q: string;
|
||||||
|
/** A department code, or "all". */
|
||||||
|
dept: string;
|
||||||
|
/** A FLT_STATUS value, or "all". */
|
||||||
|
status: string;
|
||||||
|
/** A station id as text, or "all". */
|
||||||
|
stall: string;
|
||||||
|
/** A welder's user id, or "all". */
|
||||||
|
who: string;
|
||||||
|
/** A FLT_FLAGKIND value, or "all". */
|
||||||
|
flag: string;
|
||||||
|
/** Quick flags that are on. */
|
||||||
|
flags: string[];
|
||||||
|
/** Board or list, per screen: {clerical: "list"}. */
|
||||||
|
views: Record<string, ViewKind>;
|
||||||
|
sort: SortKey;
|
||||||
|
/** The advanced row is open. */
|
||||||
|
more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FLT_DEFAULT: Filters = {
|
||||||
|
q: "",
|
||||||
|
dept: ALL,
|
||||||
|
status: ALL,
|
||||||
|
stall: ALL,
|
||||||
|
who: ALL,
|
||||||
|
flag: ALL,
|
||||||
|
flags: [],
|
||||||
|
views: {},
|
||||||
|
sort: "updated",
|
||||||
|
more: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FLT_KEY = "ps.flt";
|
||||||
|
export const PROJ_KEY = "ps.proj";
|
||||||
|
|
||||||
|
function lsGet(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lsSet(key: string, value: string | null): void {
|
||||||
|
try {
|
||||||
|
if (value === null) window.localStorage.removeItem(key);
|
||||||
|
else window.localStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
// Storage blocked: the filters last for this page only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORT_KEYS = new Set<string>(FLT_SORT.map(([k]) => k));
|
||||||
|
const FLAG_KEYS = new Set<string>(FLT_FLAGS.map(([k]) => k));
|
||||||
|
const str = (v: unknown, d: string) => (typeof v === "string" && v ? v : d);
|
||||||
|
|
||||||
|
/** ps.flt merged over the default. q is always empty. Bad values fall back to the default. */
|
||||||
|
export function loadFilters(): Filters {
|
||||||
|
let raw: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(lsGet(FLT_KEY) ?? "{}") as unknown;
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) raw = parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
raw = {};
|
||||||
|
}
|
||||||
|
const views: Record<string, ViewKind> = {};
|
||||||
|
if (raw.views && typeof raw.views === "object") {
|
||||||
|
for (const [k, v] of Object.entries(raw.views as Record<string, unknown>)) if (v === "board" || v === "list") views[k] = v;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
q: "",
|
||||||
|
dept: str(raw.dept, ALL),
|
||||||
|
status: str(raw.status, ALL),
|
||||||
|
stall: str(raw.stall, ALL),
|
||||||
|
who: str(raw.who, ALL),
|
||||||
|
flag: str(raw.flag, ALL),
|
||||||
|
flags: Array.isArray(raw.flags) ? raw.flags.filter((x): x is string => typeof x === "string" && FLAG_KEYS.has(x)) : [],
|
||||||
|
views,
|
||||||
|
sort: typeof raw.sort === "string" && SORT_KEYS.has(raw.sort) ? (raw.sort as SortKey) : "updated",
|
||||||
|
more: raw.more === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The saved form: everything but q. */
|
||||||
|
export function savedForm(f: Filters): Omit<Filters, "q"> {
|
||||||
|
const { q: _q, ...rest } = f;
|
||||||
|
void _q;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves to ps.flt without q. */
|
||||||
|
export function saveFilters(f: Filters): void {
|
||||||
|
lsSet(FLT_KEY, JSON.stringify(savedForm(f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- the project pick (ps.proj) ---------- */
|
||||||
|
|
||||||
|
interface SavedPick {
|
||||||
|
project_id: number;
|
||||||
|
unit_id: number | null;
|
||||||
|
project: ProjectRef;
|
||||||
|
unit: Unit | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NO_PICK: UnitPick = { project: null, unit: null };
|
||||||
|
|
||||||
|
export function loadPick(): UnitPick {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(lsGet(PROJ_KEY) ?? "null") as SavedPick | null;
|
||||||
|
if (!raw || typeof raw !== "object" || typeof raw.project_id !== "number" || !raw.project) return NO_PICK;
|
||||||
|
const unit = raw.unit && typeof raw.unit.id === "number" && raw.unit.id === raw.unit_id ? raw.unit : null;
|
||||||
|
return { project: { ...raw.project, id: raw.project_id }, unit };
|
||||||
|
} catch {
|
||||||
|
return NO_PICK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePick(p: UnitPick): void {
|
||||||
|
if (!p.project) {
|
||||||
|
lsSet(PROJ_KEY, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved: SavedPick = { project_id: p.project.id, unit_id: p.unit?.id ?? null, project: p.project, unit: p.unit };
|
||||||
|
lsSet(PROJ_KEY, JSON.stringify(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- what each screen shows ---------- */
|
||||||
|
|
||||||
|
/** Which find bar parts a screen shows. Hidden parts never filter. */
|
||||||
|
export interface ScreenParts {
|
||||||
|
project: boolean;
|
||||||
|
dept: boolean;
|
||||||
|
stall: boolean;
|
||||||
|
who: boolean;
|
||||||
|
flag: boolean;
|
||||||
|
mine: boolean;
|
||||||
|
view: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partsFor(screen: Screen): ScreenParts {
|
||||||
|
const stall = screen === "stall";
|
||||||
|
return {
|
||||||
|
project: !stall,
|
||||||
|
dept: !stall,
|
||||||
|
stall: !stall,
|
||||||
|
who: !stall,
|
||||||
|
flag: screen === "clerical" || screen === "pm",
|
||||||
|
mine: !stall && screen !== "pm",
|
||||||
|
view: screen !== "pm",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The filters with the parts the screen hides set back to their default. */
|
||||||
|
export function effective(f: Filters, screen?: Screen): Filters {
|
||||||
|
if (!screen) return f;
|
||||||
|
const s = partsFor(screen);
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
dept: s.dept ? f.dept : ALL,
|
||||||
|
stall: s.stall ? f.stall : ALL,
|
||||||
|
who: s.who ? f.who : ALL,
|
||||||
|
flag: s.flag ? f.flag : ALL,
|
||||||
|
flags: s.mine ? f.flags : f.flags.filter((x) => x !== "mine"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old fltAdvCount: the advanced selects that are set. */
|
||||||
|
export function countAdvanced(f: Filters, screen?: Screen): number {
|
||||||
|
const e = effective(f, screen);
|
||||||
|
return [e.dept, e.status, e.stall, e.who, e.flag].filter((v) => v !== ALL).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old fltCountActive: everything that narrows the boards, the project pick included. */
|
||||||
|
export function countActive(f: Filters, projectPicked: boolean, screen?: Screen): number {
|
||||||
|
const e = effective(f, screen);
|
||||||
|
return (e.q.trim() ? 1 : 0) + countAdvanced(e) + e.flags.length + (projectPicked ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The project and unit part of a query. KPI numbers follow only this. */
|
||||||
|
export function pickQuery(pick: UnitPick): PacketListQuery {
|
||||||
|
if (pick.unit) return { unit_id: pick.unit.id };
|
||||||
|
if (pick.project) return { project_id: pick.project.id };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server query for the filters and the pick. Defaults are left out. */
|
||||||
|
export function toQuery(f: Filters, pick: UnitPick, opts: { screen: Screen; stationId?: number | null }): PacketListQuery {
|
||||||
|
const e = effective(f, opts.screen);
|
||||||
|
const out: PacketListQuery = {};
|
||||||
|
const q = e.q.trim();
|
||||||
|
if (q) out.q = q;
|
||||||
|
if (e.dept !== ALL) out.dept = e.dept;
|
||||||
|
if (e.status !== ALL) out.status = e.status;
|
||||||
|
if (e.stall !== ALL && Number.isFinite(Number(e.stall))) out.station_id = Number(e.stall);
|
||||||
|
if (e.who !== ALL) out.welder_id = e.who;
|
||||||
|
if (e.flag !== ALL) out.flag = e.flag;
|
||||||
|
if (e.flags.length) {
|
||||||
|
out.flags = [...e.flags].sort((a, b) => FLT_FLAGS.findIndex(([k]) => k === a) - FLT_FLAGS.findIndex(([k]) => k === b)).join(",");
|
||||||
|
if (e.flags.includes("mine")) {
|
||||||
|
out.mine_screen = opts.screen;
|
||||||
|
if (opts.stationId) out.mine_station_id = opts.stationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.sort !== "updated") out.sort = e.sort;
|
||||||
|
return { ...out, ...pickQuery(pick) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- KPI taps ---------- */
|
||||||
|
|
||||||
|
export type KpiKey = "status" | "flag";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A KPI tap toggles its filter. flag with a quick flag value (co, short, ...) toggles the chip; flag with a flag
|
||||||
|
* kind (any, rev, ...) toggles the Flag select. Turning a select on opens the advanced row.
|
||||||
|
*/
|
||||||
|
export function kpiFilter(f: Filters, key: KpiKey, value: string): Partial<Filters> {
|
||||||
|
if (key === "flag" && FLAG_KEYS.has(value)) {
|
||||||
|
return { flags: f.flags.includes(value) ? f.flags.filter((x) => x !== value) : [...f.flags, value] };
|
||||||
|
}
|
||||||
|
const field = key === "flag" ? "flag" : "status";
|
||||||
|
const on = f[field] !== value;
|
||||||
|
return on ? { [field]: value, more: true } : { [field]: ALL };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kpiActive(f: Filters, key: KpiKey, value: string): boolean {
|
||||||
|
if (key === "flag" && FLAG_KEYS.has(value)) return f.flags.includes(value);
|
||||||
|
return (key === "flag" ? f.flag : f.status) === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrolls the find bar into view, after the render that follows a KPI tap. */
|
||||||
|
export function scrollToFindBar(): void {
|
||||||
|
nextFrame(() => {
|
||||||
|
const el = document.querySelector<HTMLElement>(".fltbar");
|
||||||
|
if (el && typeof el.scrollIntoView === "function") {
|
||||||
|
el.scrollIntoView({ behavior: document.body.classList.contains("motion") ? "smooth" : "auto", block: "start" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- the hook ---------- */
|
||||||
|
|
||||||
|
export interface FiltersState {
|
||||||
|
f: Filters;
|
||||||
|
set: (patch: Partial<Filters>) => void;
|
||||||
|
clear: () => void;
|
||||||
|
pick: UnitPick;
|
||||||
|
setPick: (p: UnitPick) => void;
|
||||||
|
view: ViewKind;
|
||||||
|
setView: (v: ViewKind) => void;
|
||||||
|
/** The whole query, with q debounced 250 ms. */
|
||||||
|
query: PacketListQuery;
|
||||||
|
queryKey: string;
|
||||||
|
/** The project and unit only, for the KPI numbers. */
|
||||||
|
scope: PacketListQuery;
|
||||||
|
scopeKey: string;
|
||||||
|
/** A KPI tap: toggles the filter and scrolls the find bar into view when it turns on. */
|
||||||
|
tapKpi: (key: KpiKey, value: string) => void;
|
||||||
|
isKpiOn: (key: KpiKey, value: string) => boolean;
|
||||||
|
/** The filters as they apply on this screen (hidden parts left out). */
|
||||||
|
active: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The find bar state for one screen. Saves on every change except q; keeps the pick in ps.proj. */
|
||||||
|
export function useFilters(screen: Screen, opts: { stationId?: number | null } = {}): FiltersState {
|
||||||
|
const [f, setF] = useState<Filters>(loadFilters);
|
||||||
|
const [pick, setPickState] = useState<UnitPick>(loadPick);
|
||||||
|
const [dq, setDq] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = f.q.trim();
|
||||||
|
if (next === dq) return;
|
||||||
|
// Clearing the box applies at once; typing waits for a pause.
|
||||||
|
if (!next) {
|
||||||
|
setDq("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = window.setTimeout(() => setDq(next), 250);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [f.q, dq]);
|
||||||
|
|
||||||
|
const saved = useMemo(() => JSON.stringify(savedForm(f)), [f]);
|
||||||
|
useEffect(() => {
|
||||||
|
lsSet(FLT_KEY, saved);
|
||||||
|
}, [saved]);
|
||||||
|
|
||||||
|
const set = useCallback((patch: Partial<Filters>) => setF((prev) => ({ ...prev, ...patch })), []);
|
||||||
|
const setPick = useCallback((p: UnitPick) => {
|
||||||
|
setPickState(p);
|
||||||
|
savePick(p);
|
||||||
|
}, []);
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setF((prev) => ({ ...FLT_DEFAULT, views: prev.views, sort: prev.sort }));
|
||||||
|
setDq("");
|
||||||
|
setPick(NO_PICK);
|
||||||
|
}, [setPick]);
|
||||||
|
|
||||||
|
const view: ViewKind = partsFor(screen).view && f.views[screen] === "list" ? "list" : "board";
|
||||||
|
const setView = useCallback((v: ViewKind) => setF((prev) => ({ ...prev, views: { ...prev.views, [screen]: v } })), [screen]);
|
||||||
|
|
||||||
|
const stationId = opts.stationId ?? null;
|
||||||
|
const query = useMemo(() => toQuery({ ...f, q: dq }, pick, { screen, stationId }), [f, dq, pick, screen, stationId]);
|
||||||
|
const queryKey = JSON.stringify(query);
|
||||||
|
const scope = useMemo(() => pickQuery(pick), [pick]);
|
||||||
|
const scopeKey = JSON.stringify(scope);
|
||||||
|
|
||||||
|
const tapKpi = useCallback(
|
||||||
|
(key: KpiKey, value: string) => {
|
||||||
|
const on = !kpiActive(f, key, value);
|
||||||
|
set(kpiFilter(f, key, value));
|
||||||
|
if (on) scrollToFindBar();
|
||||||
|
},
|
||||||
|
[f, set],
|
||||||
|
);
|
||||||
|
const isKpiOn = useCallback((key: KpiKey, value: string) => kpiActive(effective(f, screen), key, value), [f, screen]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
f,
|
||||||
|
set,
|
||||||
|
clear,
|
||||||
|
pick,
|
||||||
|
setPick,
|
||||||
|
view,
|
||||||
|
setView,
|
||||||
|
query,
|
||||||
|
queryKey,
|
||||||
|
scope,
|
||||||
|
scopeKey,
|
||||||
|
tapKpi,
|
||||||
|
isKpiOn,
|
||||||
|
active: countActive(f, !!pick.project, screen),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- local sort (the board columns merged into one list) ---------- */
|
||||||
|
|
||||||
|
const natural = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true });
|
||||||
|
const byUpdatedDesc = (a: PacketCard, b: PacketCard) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "");
|
||||||
|
|
||||||
|
/** The same order the server's sort= gives, for lists merged from several board columns. */
|
||||||
|
export function sorter(k: SortKey): (a: PacketCard, b: PacketCard) => number {
|
||||||
|
switch (k) {
|
||||||
|
case "project":
|
||||||
|
return (a, b) =>
|
||||||
|
natural(a.project.number, b.project.number) || natural(a.unit.number, b.unit.number) || (a.op ?? "").localeCompare(b.op ?? "");
|
||||||
|
case "due":
|
||||||
|
return (a, b) => (a.due_effective ?? "9999").localeCompare(b.due_effective ?? "9999") || byUpdatedDesc(a, b);
|
||||||
|
case "status":
|
||||||
|
return (a, b) => STATUSES.indexOf(a.status) - STATUSES.indexOf(b.status) || byUpdatedDesc(a, b);
|
||||||
|
case "dept":
|
||||||
|
return (a, b) => natural(a.dept.code, b.dept.code) || byUpdatedDesc(a, b);
|
||||||
|
default:
|
||||||
|
return byUpdatedDesc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cards from several columns, once each, in column order. */
|
||||||
|
export function uniqueCards<T extends { id: number }>(lists: ReadonlyArray<readonly T[]>): T[] {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const c of list) {
|
||||||
|
if (seen.has(c.id)) continue;
|
||||||
|
seen.add(c.id);
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { memoryStorage } from "../screens/supervisor/memoryStorage";
|
||||||
|
import { applyMotionClass, countStep, MOTION_KEY, motionOn, setMotion, useMotion } from "./motion";
|
||||||
|
|
||||||
|
function media(reduce: boolean) {
|
||||||
|
vi.stubGlobal("matchMedia", (q: string) => ({ matches: reduce && q.includes("reduce"), addEventListener: () => undefined, removeEventListener: () => undefined }));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("localStorage", memoryStorage());
|
||||||
|
document.body.classList.remove("motion");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("motion", () => {
|
||||||
|
it("is on by default and off when ps.motion says off", () => {
|
||||||
|
media(false);
|
||||||
|
expect(motionOn()).toBe(true);
|
||||||
|
window.localStorage.setItem(MOTION_KEY, "off");
|
||||||
|
expect(motionOn()).toBe(false);
|
||||||
|
window.localStorage.setItem(MOTION_KEY, "anything");
|
||||||
|
expect(motionOn()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reduced motion always wins", () => {
|
||||||
|
media(true);
|
||||||
|
window.localStorage.setItem(MOTION_KEY, "on");
|
||||||
|
expect(motionOn()).toBe(false);
|
||||||
|
applyMotionClass();
|
||||||
|
expect(document.body).not.toHaveClass("motion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setMotion writes the key, sets the body class and updates useMotion", () => {
|
||||||
|
media(false);
|
||||||
|
const { result } = renderHook(() => useMotion());
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
act(() => setMotion(false));
|
||||||
|
expect(window.localStorage.getItem(MOTION_KEY)).toBe("off");
|
||||||
|
expect(document.body).not.toHaveClass("motion");
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
act(() => setMotion(true));
|
||||||
|
expect(window.localStorage.getItem(MOTION_KEY)).toBe("on");
|
||||||
|
expect(document.body).toHaveClass("motion");
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts with an ease-out cubic", () => {
|
||||||
|
expect(countStep(10, 20, 0)).toBe(10);
|
||||||
|
expect(countStep(10, 20, 0.5)).toBe(19);
|
||||||
|
expect(countStep(10, 20, 1)).toBe(20);
|
||||||
|
expect(countStep(20, 10, 2)).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Motion (the old motion.js): short animation that shows what changed. One switch per browser in the top bar.
|
||||||
|
// prefers-reduced-motion: reduce always wins. body.motion is set while motion is on; the CSS keys off it.
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
export const MOTION_KEY = "ps.motion";
|
||||||
|
const REDUCE = "(prefers-reduced-motion: reduce)";
|
||||||
|
|
||||||
|
function reduced(): boolean {
|
||||||
|
try {
|
||||||
|
return typeof window.matchMedia === "function" && window.matchMedia(REDUCE).matches;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stored(): string | null {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(MOTION_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On unless the browser asks for reduced motion or the switch says off. */
|
||||||
|
export function motionOn(): boolean {
|
||||||
|
if (reduced()) return false;
|
||||||
|
return stored() !== "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** body.motion follows motionOn(). */
|
||||||
|
export function applyMotionClass(): void {
|
||||||
|
if (typeof document === "undefined" || !document.body) return;
|
||||||
|
document.body.classList.toggle("motion", motionOn());
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
function emit(): void {
|
||||||
|
applyMotionClass();
|
||||||
|
for (const fn of listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns motion on or off for this browser. Reduced motion still wins. */
|
||||||
|
export function setMotion(on: boolean): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(MOTION_KEY, on ? "on" : "off");
|
||||||
|
} catch {
|
||||||
|
// Storage blocked: the switch lasts for this page only.
|
||||||
|
}
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
let mq: MediaQueryList | null = null;
|
||||||
|
try {
|
||||||
|
mq = typeof window.matchMedia === "function" ? window.matchMedia(REDUCE) : null;
|
||||||
|
} catch {
|
||||||
|
mq = null;
|
||||||
|
}
|
||||||
|
const onMq = () => emit();
|
||||||
|
mq?.addEventListener?.("change", onMq);
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === MOTION_KEY) emit();
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
mq?.removeEventListener?.("change", onMq);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether motion is on, kept current with the switch, other tabs and the reduced-motion setting. */
|
||||||
|
export function useMotion(): boolean {
|
||||||
|
return useSyncExternalStore(subscribe, motionOn, () => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restarts a CSS animation class on an element (remove, reflow, add), and takes it off after `ms`. */
|
||||||
|
export function replayClass(el: HTMLElement | null, cls: string, ms: number): () => void {
|
||||||
|
if (!el) return () => undefined;
|
||||||
|
el.classList.remove(cls);
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add(cls);
|
||||||
|
const t = window.setTimeout(() => el.classList.remove(cls), ms);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(t);
|
||||||
|
el.classList.remove(cls);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The KPI count-up: 420 ms, then the tile keeps .m-bump for 500 ms. */
|
||||||
|
export const COUNT_MS = 420;
|
||||||
|
export const BUMP_MS = 500;
|
||||||
|
|
||||||
|
/** Ease-out cubic from a to z at fraction t, rounded to a whole number. */
|
||||||
|
export function countStep(a: number, z: number, t: number): number {
|
||||||
|
const f = Math.min(1, Math.max(0, t));
|
||||||
|
return Math.round(a + (z - a) * (1 - Math.pow(1 - f, 3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** requestAnimationFrame, or a 16 ms timer where there is none. */
|
||||||
|
export function nextFrame(cb: (t: number) => void): number {
|
||||||
|
if (typeof window.requestAnimationFrame === "function") return window.requestAnimationFrame(cb);
|
||||||
|
return window.setTimeout(() => cb(performance.now()), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelFrame(id: number): void {
|
||||||
|
if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(id);
|
||||||
|
else window.clearTimeout(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import { createMemoryRouter, Outlet, RouterProvider, useNavigate } from "react-router";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { rememberScroll, resetScroll, savedScroll, SCROLL_KEEP, useScrollRestore } from "./scrollRestore";
|
||||||
|
|
||||||
|
let y = 0;
|
||||||
|
const scrollTo = vi.fn((_x: number, to: number) => {
|
||||||
|
y = to;
|
||||||
|
});
|
||||||
|
|
||||||
|
function Frame() {
|
||||||
|
useScrollRestore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => navigate("/packets/1")}>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => navigate(-1)}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetScroll();
|
||||||
|
y = 0;
|
||||||
|
scrollTo.mockClear();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
Object.defineProperty(window, "scrollY", { configurable: true, get: () => y });
|
||||||
|
vi.stubGlobal("scrollTo", scrollTo);
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 16));
|
||||||
|
vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scroll restore", () => {
|
||||||
|
it("keeps at most 40 positions, oldest out first", () => {
|
||||||
|
for (let i = 0; i < SCROLL_KEEP + 5; i++) rememberScroll(`k${i}`, i * 10);
|
||||||
|
expect(savedScroll("k0")).toBeNull();
|
||||||
|
expect(savedScroll("k4")).toBeNull();
|
||||||
|
expect(savedScroll("k5")).toBe(50);
|
||||||
|
expect(savedScroll(`k${SCROLL_KEEP + 4}`)).toBe((SCROLL_KEEP + 4) * 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a push starts at the top, and Back lands on the same spot", () => {
|
||||||
|
const router = createMemoryRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
element: <Frame />,
|
||||||
|
children: [
|
||||||
|
{ path: "/supervisor", element: <h1>Home</h1> },
|
||||||
|
{ path: "/packets/:id", element: <h1>Packet</h1> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ initialEntries: ["/supervisor"] },
|
||||||
|
);
|
||||||
|
render(<RouterProvider router={router} />);
|
||||||
|
// The person scrolls down the board.
|
||||||
|
y = 900;
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
act(() => screen.getByRole("button", { name: "Open" }).click());
|
||||||
|
expect(screen.getByRole("heading", { name: "Packet" })).toBeInTheDocument();
|
||||||
|
expect(y).toBe(0);
|
||||||
|
act(() => screen.getByRole("button", { name: "Back" }).click());
|
||||||
|
expect(screen.getByRole("heading", { name: "Home" })).toBeInTheDocument();
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
expect(y).toBe(900);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Back lands on the same spot (the old navPush and navBack). Layout records window.scrollY per history entry
|
||||||
|
// before the next page draws, and puts it back on a POP after the page has drawn.
|
||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
import { useLocation, useNavigationType } from "react-router";
|
||||||
|
import { cancelFrame, nextFrame } from "./motion";
|
||||||
|
|
||||||
|
/** At most this many positions are kept. */
|
||||||
|
export const SCROLL_KEEP = 40;
|
||||||
|
|
||||||
|
const positions = new Map<string, number>();
|
||||||
|
|
||||||
|
/** Remembers the scroll position of one history entry. The oldest go first past SCROLL_KEEP. */
|
||||||
|
export function rememberScroll(key: string, y: number): void {
|
||||||
|
positions.delete(key);
|
||||||
|
positions.set(key, Math.max(0, Math.round(y)));
|
||||||
|
while (positions.size > SCROLL_KEEP) {
|
||||||
|
const oldest = positions.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
positions.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savedScroll(key: string): number | null {
|
||||||
|
return positions.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For tests. */
|
||||||
|
export function resetScroll(): void {
|
||||||
|
positions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollTo(y: number): void {
|
||||||
|
if (Math.abs(window.scrollY - y) < 1) return;
|
||||||
|
try {
|
||||||
|
window.scrollTo(0, y);
|
||||||
|
} catch {
|
||||||
|
// jsdom has no scrolling.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extra tries after the 150 ms one, for a board whose data lands later. Each stops once the page is there. */
|
||||||
|
export const RESTORE_RETRIES_MS = [400, 1000];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores `y` after the page has drawn: on the next frame, and again at 150 ms once the screen has eased in
|
||||||
|
* (the old way), then at 400 and 1000 ms while the page is still short of it. The user's own wheel, touch or
|
||||||
|
* key stops it. Returns a cancel function.
|
||||||
|
*/
|
||||||
|
export function restoreScroll(y: number): () => void {
|
||||||
|
const timers: number[] = [];
|
||||||
|
let stopped = false;
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
for (const t of timers) window.clearTimeout(t);
|
||||||
|
cancelFrame(raf);
|
||||||
|
for (const ev of USER_EVENTS) window.removeEventListener(ev, stop);
|
||||||
|
};
|
||||||
|
const go = (last: boolean) => {
|
||||||
|
if (stopped) return;
|
||||||
|
scrollTo(y);
|
||||||
|
if (last) stop();
|
||||||
|
};
|
||||||
|
for (const ev of USER_EVENTS) window.addEventListener(ev, stop, { passive: true });
|
||||||
|
const raf = nextFrame(() => go(false));
|
||||||
|
timers.push(window.setTimeout(() => go(false), 150));
|
||||||
|
RESTORE_RETRIES_MS.forEach((ms, i) => timers.push(window.setTimeout(() => go(i === RESTORE_RETRIES_MS.length - 1), ms)));
|
||||||
|
return stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_EVENTS = ["wheel", "touchstart", "keydown"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the scroll position per location.key. A POP (Back, Forward) restores the entry's position; a PUSH or
|
||||||
|
* REPLACE to a new path starts at the top.
|
||||||
|
*/
|
||||||
|
export function useScrollRestore(): void {
|
||||||
|
const loc = useLocation();
|
||||||
|
const nav = useNavigationType();
|
||||||
|
const prev = useRef<{ key: string; path: string } | null>(null);
|
||||||
|
const yNow = useRef(0);
|
||||||
|
const pending = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// The last known position of the page being left. A scroll listener keeps it, because by the time the
|
||||||
|
// new location renders the old page may already be gone and the window clamped.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const on = () => {
|
||||||
|
yNow.current = window.scrollY;
|
||||||
|
};
|
||||||
|
on();
|
||||||
|
window.addEventListener("scroll", on, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", on);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const before = prev.current;
|
||||||
|
if (!before || before.key === loc.key) {
|
||||||
|
prev.current = { key: loc.key, path: loc.pathname };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rememberScroll(before.key, yNow.current);
|
||||||
|
prev.current = { key: loc.key, path: loc.pathname };
|
||||||
|
pending.current?.();
|
||||||
|
pending.current = null;
|
||||||
|
if (nav === "POP") {
|
||||||
|
const y = savedScroll(loc.key);
|
||||||
|
if (y !== null) pending.current = restoreScroll(y);
|
||||||
|
} else if (before.path !== loc.pathname) {
|
||||||
|
scrollTo(0);
|
||||||
|
}
|
||||||
|
}, [loc.key, loc.pathname, nav]);
|
||||||
|
|
||||||
|
// A restore still running when the app unmounts stops.
|
||||||
|
useLayoutEffect(
|
||||||
|
() => () => {
|
||||||
|
pending.current?.();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Cards glide to their new place (FLIP), new cards fade in, and cards changed elsewhere glow (the old motionAfter).
|
||||||
|
import { useLayoutEffect, useRef, type RefObject } from "react";
|
||||||
|
import { motionOn } from "./motion";
|
||||||
|
|
||||||
|
export const FLIP_EASE = "cubic-bezier(.2,.7,.2,1)";
|
||||||
|
/** More moves or new cards than this: the board just appears (a filter, a big sync). */
|
||||||
|
export const FLIP_MAX = 40;
|
||||||
|
|
||||||
|
const GLOW: Keyframe[] = [
|
||||||
|
{ boxShadow: "0 0 0 0 rgba(73,192,222,0)" },
|
||||||
|
{ boxShadow: "0 0 0 3px rgba(73,192,222,.75), 0 0 22px rgba(73,192,222,.45)", offset: 0.15 },
|
||||||
|
{ boxShadow: "0 0 0 0 rgba(73,192,222,0)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Place {
|
||||||
|
box: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The column or section a card sits in. A panel opening above pushes every card down; that is not a move. */
|
||||||
|
function boxOf(el: Element): Element | null {
|
||||||
|
return el.closest(".col") ?? el.closest("section") ?? el.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boxKey(c: Element | null): string {
|
||||||
|
if (!c) return "";
|
||||||
|
const h = c.querySelector(".col-h, .sec-h h2, h2, h3");
|
||||||
|
return `${c.className}|${(h?.textContent ?? "").replace(/\d+/g, "").trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a card sits: inside its column, and inside the board (so a page scroll is not a move). */
|
||||||
|
function placeOf(el: Element, rootRect: { left: number; top: number }): Place {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const c = boxOf(el);
|
||||||
|
const cr = c ? c.getBoundingClientRect() : { left: 0, top: 0 };
|
||||||
|
return { box: boxKey(c), x: r.left - cr.left, y: r.top - cr.top, left: r.left - rootRect.left, top: r.top - rootRect.top };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves to animate: [element, dx, dy] from the old place to the new. */
|
||||||
|
export function flipMoves(before: ReadonlyMap<string, Place>, after: ReadonlyArray<[string, Element, Place]>): Array<[Element, number, number]> {
|
||||||
|
const out: Array<[Element, number, number]> = [];
|
||||||
|
for (const [k, el, n] of after) {
|
||||||
|
const o = before.get(k);
|
||||||
|
if (!o) continue;
|
||||||
|
const same = o.box === n.box;
|
||||||
|
const dx = same ? o.x - n.x : o.left - n.left;
|
||||||
|
const dy = same ? o.y - n.y : o.top - n.top;
|
||||||
|
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) out.push([el, dx, dy]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FLIP for the cards on the page that holds `ref` (its `main`, else `ref` itself). `sigs` maps a card id to its signature (status, wh stage, station, open CO
|
||||||
|
* count); a card whose signature changed since the last render glows. Cards carry data-card-id.
|
||||||
|
* Before the dependent render the rects are taken from the last layout; after it, moved cards glide.
|
||||||
|
*/
|
||||||
|
export function useFlip(ref: RefObject<HTMLElement | null>, selector = "[data-card-id]", deps: readonly unknown[] = [], sigs?: ReadonlyMap<string, string>): void {
|
||||||
|
const last = useRef<Map<string, Place>>(new Map());
|
||||||
|
const lastSig = useRef<Map<string, string>>(new Map());
|
||||||
|
const first = useRef(true);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
// Cards are tracked across the whole page (the old M_SEL under #main), so a card that leaves a board for the
|
||||||
|
// station grid, or comes back, still glides. One board per screen drives it.
|
||||||
|
const root = ref.current?.closest("main") ?? ref.current;
|
||||||
|
if (!root) return;
|
||||||
|
const els = Array.from(root.querySelectorAll(selector));
|
||||||
|
const rootRect = root.getBoundingClientRect();
|
||||||
|
const now: Array<[string, Element, Place]> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const el of els) {
|
||||||
|
const k = (el as HTMLElement).dataset.cardId ?? "";
|
||||||
|
if (!k || seen.has(k)) continue;
|
||||||
|
seen.add(k);
|
||||||
|
now.push([k, el, placeOf(el, rootRect)]);
|
||||||
|
}
|
||||||
|
const before = last.current;
|
||||||
|
last.current = new Map(now.map(([k, , p]) => [k, p]));
|
||||||
|
|
||||||
|
const changed = new Set<string>();
|
||||||
|
if (sigs) {
|
||||||
|
for (const [k, s] of sigs) {
|
||||||
|
const o = lastSig.current.get(k);
|
||||||
|
if (o !== undefined && o !== s) changed.add(k);
|
||||||
|
}
|
||||||
|
lastSig.current = new Map(sigs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasFirst = first.current;
|
||||||
|
first.current = false;
|
||||||
|
if (wasFirst || !motionOn() || typeof (Element.prototype as Partial<Element>).animate !== "function") return;
|
||||||
|
|
||||||
|
const vh = window.innerHeight || 900;
|
||||||
|
const visible = now.filter(([, , p]) => p.top + rootRect.top > -200 && p.top + rootRect.top < vh + 200);
|
||||||
|
const moves = flipMoves(before, visible);
|
||||||
|
const fresh = before.size ? visible.filter(([k]) => !before.has(k)).map(([, el]) => el) : [];
|
||||||
|
if (moves.length <= FLIP_MAX) {
|
||||||
|
for (const [el, dx, dy] of moves) {
|
||||||
|
el.animate([{ transform: `translate(${dx}px,${dy}px)`, zIndex: 5 }, { transform: "none", zIndex: 5 }], {
|
||||||
|
duration: Math.min(420, 220 + Math.hypot(dx, dy) / 6),
|
||||||
|
easing: FLIP_EASE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fresh.length <= FLIP_MAX) {
|
||||||
|
for (const el of fresh) el.animate([{ opacity: 0, transform: "scale(.97)" }, { opacity: 1, transform: "none" }], { duration: 240, easing: FLIP_EASE });
|
||||||
|
}
|
||||||
|
for (const [k, el] of now) if (changed.has(k)) el.animate(GLOW, { duration: 1700, easing: "ease-out" });
|
||||||
|
// The caller lists what the render depends on.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A card's signature: what changing elsewhere makes it glow. */
|
||||||
|
export function cardSig(p: { status: string; wh_stage?: string | null; station?: { id: number } | null; open_cos?: unknown }): string {
|
||||||
|
const cos = Array.isArray(p.open_cos) ? p.open_cos.length : Number(p.open_cos) || 0;
|
||||||
|
return [p.status, p.wh_stage ?? "", p.station?.id ?? "", cos].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signatures for a list of cards. */
|
||||||
|
export function sigMap(cards: Iterable<{ id: number } & Parameters<typeof cardSig>[0]>): Map<string, string> {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const c of cards) m.set(String(c.id), cardSig(c));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ import { createRoot } from "react-dom/client";
|
|||||||
import "./theme/fonts";
|
import "./theme/fonts";
|
||||||
import "./theme/theme.css";
|
import "./theme/theme.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { applyMotionClass } from "./lib/motion";
|
||||||
|
|
||||||
|
// body.motion before the first paint, so nothing animates for someone who turned it off.
|
||||||
|
applyMotionClass();
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if (!root) throw new Error("Missing #root element");
|
if (!root) throw new Error("Missing #root element");
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
|
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router";
|
||||||
import { assetsApi } from "../api/assets";
|
import { assetsApi } from "../api/assets";
|
||||||
import { ApiError, errorCode } from "../api/client";
|
import { ApiError, errorCode } from "../api/client";
|
||||||
import { packetsApi } from "../api/packets";
|
import { packetsApi } from "../api/packets";
|
||||||
import type { PacketHeader, PacketRead, TransitionBody } from "../api/types";
|
import type { PacketHeader, PacketRead, TransitionBody } from "../api/types";
|
||||||
import { homePath } from "../auth/roles";
|
import { homePath } from "../auth/roles";
|
||||||
import { useAuth } from "../auth/useAuth";
|
import { useAuth } from "../auth/useAuth";
|
||||||
import { usePacketPresence } from "../auth/useEvents";
|
import { presenceText, usePacketPresence } from "../auth/useEvents";
|
||||||
import { useGuard } from "../components/useGuard";
|
import { useGuard } from "../components/useGuard";
|
||||||
import { useChanged } from "../lib/changes";
|
import { useChanged } from "../lib/changes";
|
||||||
import { errorText } from "../lib/format";
|
import { errorText } from "../lib/format";
|
||||||
@@ -116,6 +116,10 @@ export function PacketPage() {
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
const others = usePacketPresence(Number.isFinite(id) ? id : null);
|
const others = usePacketPresence(Number.isFinite(id) ? id : null);
|
||||||
|
// Phase 8: Back is one step back in this app's history (to the same scroll spot); a packet opened straight
|
||||||
|
// from a link has no app entry before it ("default"), so Back goes to the screen home.
|
||||||
|
const location = useLocation();
|
||||||
|
const hasAppHistory = location.key !== "default";
|
||||||
const versionRef = useRef<number | null>(null);
|
const versionRef = useRef<number | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -342,7 +346,15 @@ export function PacketPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link className="back" to={backTo}>
|
<Link
|
||||||
|
className="back"
|
||||||
|
to={backTo}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!hasAppHistory || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(-1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
← Back
|
← Back
|
||||||
</Link>
|
</Link>
|
||||||
<div className="dhead">
|
<div className="dhead">
|
||||||
@@ -464,7 +476,7 @@ export function PacketPage() {
|
|||||||
{others.length ? (
|
{others.length ? (
|
||||||
<div className="presence small" data-testid="presence" role="status">
|
<div className="presence small" data-testid="presence" role="status">
|
||||||
<span className="pdot" aria-hidden="true" />
|
<span className="pdot" aria-hidden="true" />
|
||||||
Also here: {others.map((u) => u.display_name).join(", ")}
|
{presenceText(others)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{msg ? (
|
{msg ? (
|
||||||
@@ -628,7 +640,7 @@ export function PacketPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div role="tabpanel" id={`ppanel-${shownTab}`} aria-labelledby={`ptab-${shownTab}`}>
|
<div key={shownTab} className="m-tab" role="tabpanel" id={`ppanel-${shownTab}`} aria-labelledby={`ptab-${shownTab}`}>
|
||||||
{shownTab === "drawing" ? (
|
{shownTab === "drawing" ? (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{draft && can("edit_document_set") && drawingPages.length ? (
|
{draft && can("edit_document_set") && drawingPages.length ? (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuth } from "../auth/useAuth";
|
|||||||
import { AdminPage } from "./admin/AdminPage";
|
import { AdminPage } from "./admin/AdminPage";
|
||||||
import { ClericalHome } from "./clerical/ClericalHome";
|
import { ClericalHome } from "./clerical/ClericalHome";
|
||||||
import { PlaceholderScreen } from "./PlaceholderScreen";
|
import { PlaceholderScreen } from "./PlaceholderScreen";
|
||||||
|
import { PmHome } from "./pm/PmHome";
|
||||||
import { QcHome } from "./qc/QcHome";
|
import { QcHome } from "./qc/QcHome";
|
||||||
import { StallHome } from "./stall/StallHome";
|
import { StallHome } from "./stall/StallHome";
|
||||||
import { SupervisorHome } from "./supervisor/SupervisorHome";
|
import { SupervisorHome } from "./supervisor/SupervisorHome";
|
||||||
@@ -31,5 +32,6 @@ export function ScreenRoute() {
|
|||||||
if (screen === "supervisor") return <SupervisorHome />;
|
if (screen === "supervisor") return <SupervisorHome />;
|
||||||
if (screen === "stall") return <StallHome />;
|
if (screen === "stall") return <StallHome />;
|
||||||
if (screen === "qc") return <QcHome />;
|
if (screen === "qc") return <QcHome />;
|
||||||
|
if (screen === "pm") return <PmHome />;
|
||||||
return <PlaceholderScreen key={screen} screen={screen} />;
|
return <PlaceholderScreen key={screen} screen={screen} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||||
import { callsTo, mockFetch } from "../../test/fetchMock";
|
import { callsTo, mockFetch } from "../../test/fetchMock";
|
||||||
|
import { memoryStorage } from "../supervisor/memoryStorage";
|
||||||
import { makeCard, makeProject, makeUnit } from "../../test/packets";
|
import { makeCard, makeProject, makeUnit } from "../../test/packets";
|
||||||
|
|
||||||
const CARDS = [
|
const CARDS = [
|
||||||
@@ -75,6 +76,10 @@ function column(title: string): HTMLElement {
|
|||||||
return screen.getByRole("heading", { name: new RegExp(`^${title}`) }).closest("section") as HTMLElement;
|
return screen.getByRole("heading", { name: new RegExp(`^${title}`) }).closest("section") as HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("localStorage", memoryStorage());
|
||||||
|
});
|
||||||
|
|
||||||
describe("ClericalHome", () => {
|
describe("ClericalHome", () => {
|
||||||
it("renders the boards and the numbers from the list answer", async () => {
|
it("renders the boards and the numbers from the list answer", async () => {
|
||||||
mockFetch(base());
|
mockFetch(base());
|
||||||
@@ -122,10 +127,12 @@ describe("ClericalHome", () => {
|
|||||||
const urls = fetch.mock.calls.map(([u]) => String(u));
|
const urls = fetch.mock.calls.map(([u]) => String(u));
|
||||||
expect(urls).toContain("/api/units?project_id=7");
|
expect(urls).toContain("/api/units?project_id=7");
|
||||||
// Choosing the project narrows the boards.
|
// 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"));
|
await waitFor(() => expect(urls.concat(fetch.mock.calls.map(([u]) => String(u)))).toContain("/api/packets?limit=500&project_id=7&facets=1"));
|
||||||
|
|
||||||
fireEvent.change(unit, { target: { value: "12" } });
|
fireEvent.change(unit, { target: { value: "12" } });
|
||||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&unit_id=12"));
|
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&unit_id=12&facets=1"));
|
||||||
|
// The pick is kept for the next visit.
|
||||||
|
expect(JSON.parse(window.localStorage.getItem("ps.proj") ?? "null")).toMatchObject({ project_id: 7, unit_id: 12 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the new project sheet posts and then opens Add units", async () => {
|
it("the new project sheet posts and then opens Add units", async () => {
|
||||||
@@ -194,35 +201,59 @@ describe("ClericalHome", () => {
|
|||||||
expect(callsTo(fetch, "POST", "/api/feed/ignored/5/pull-in")).toEqual([{ reason: "Customer wants it here" }]);
|
expect(callsTo(fetch, "POST", "/api/feed/ignored/5/pull-in")).toEqual([{ reason: "Customer wants it here" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the flag kind select filters the list and the Flagged column shows the flag text", async () => {
|
it("the Flag select sends the flag kind and the Flagged column shows the flag text; the numbers stay", async () => {
|
||||||
const flagged = makeCard({ id: 9, source_kind: "visual", flag: "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3", flag_kinds: ["rev_mismatch"] });
|
const flagged = makeCard({ id: 9, source_kind: "visual", flag: "Rev mismatch: VISUAL calls rev 2, M-Files has rev 3", flag_kinds: ["rev_mismatch"] });
|
||||||
const fetch = mockFetch({
|
const fetch = mockFetch({
|
||||||
...base(),
|
...base(),
|
||||||
"/api/packets?limit=500&flag=rev_mismatch": { body: { packets: [flagged] } },
|
"/api/packets?limit=500&flag=rev": { body: { packets: [flagged] } },
|
||||||
});
|
});
|
||||||
renderApp("/clerical");
|
renderApp("/clerical");
|
||||||
const bar = await screen.findByRole("region", { name: "Find jobs" });
|
const bar = await screen.findByRole("region", { name: "Find jobs" });
|
||||||
const select = within(bar).getByLabelText("Flags");
|
fireEvent.click(within(bar).getByRole("button", { name: /^Filters/ }));
|
||||||
|
const select = within(bar).getByLabelText("Flag");
|
||||||
expect(within(select).getAllByRole("option").map((o) => o.textContent)).toEqual([
|
expect(within(select).getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||||
"All packets",
|
"Any",
|
||||||
"All flags",
|
"Flagged (any reason)",
|
||||||
"Drawing needed",
|
"Rev mismatch with M-Files",
|
||||||
"Rev mismatch",
|
"Rev not confirmed yet",
|
||||||
"Not released",
|
"Made or uploaded by hand",
|
||||||
"Not found",
|
"Still in drafting",
|
||||||
"Lookup failed",
|
"Drawing not found in M-Files",
|
||||||
"Work order gone",
|
"Drawing couldn't be read",
|
||||||
"Cancelled",
|
"Drawing being looked up",
|
||||||
"Hand upload",
|
"Not flagged",
|
||||||
]);
|
]);
|
||||||
fireEvent.change(select, { target: { value: "rev_mismatch" } });
|
fireEvent.change(select, { target: { value: "rev" } });
|
||||||
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&flag=rev_mismatch"));
|
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&flag=rev"));
|
||||||
const col = await waitFor(() => {
|
const col = await waitFor(() => {
|
||||||
const c = column("Flagged");
|
const c = column("Flagged");
|
||||||
expect(within(c).getByText(/Rev mismatch: VISUAL calls rev 2/)).toHaveClass("due", "over");
|
expect(within(c).getByText(/Rev mismatch: VISUAL calls rev 2/)).toHaveClass("due", "over");
|
||||||
return c;
|
return c;
|
||||||
});
|
});
|
||||||
expect(within(col).queryByText("L999001 · WELD-PART", { exact: false })).toBeInTheDocument();
|
expect(within(col).getAllByRole("link")).toHaveLength(1);
|
||||||
|
// The numbers follow the project pick only.
|
||||||
expect(screen.getByLabelText("Flagged: 1")).toBeInTheDocument();
|
expect(screen.getByLabelText("Flagged: 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("In the shop: 2")).toBeInTheDocument();
|
||||||
|
expect(within(bar).getByRole("button", { name: "Clear filters (1)" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a KPI tap filters the boards, the quick chips go to the server, Clear filters resets them", async () => {
|
||||||
|
const fetch = mockFetch(base());
|
||||||
|
renderApp("/clerical");
|
||||||
|
const tile = await screen.findByRole("button", { name: "With warehouse: 1" });
|
||||||
|
fireEvent.click(tile);
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&status=warehouse"));
|
||||||
|
expect(screen.getByRole("button", { name: "With warehouse: 1" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
const bar = screen.getByRole("region", { name: "Find jobs" });
|
||||||
|
fireEvent.click(within(bar).getByRole("button", { name: "Shorts open" }));
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&status=warehouse&flags=short"));
|
||||||
|
fireEvent.click(within(bar).getByRole("button", { name: "Mine" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/packets?limit=500&status=warehouse&flags=short%2Cmine&mine_screen=clerical"),
|
||||||
|
);
|
||||||
|
fireEvent.click(within(bar).getByRole("button", { name: "Clear filters (3)" }));
|
||||||
|
await waitFor(() => expect(screen.getByRole("button", { name: "With warehouse: 1" })).toHaveAttribute("aria-pressed", "false"));
|
||||||
|
expect(within(bar).getByRole("button", { name: "Shorts open" })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
expect(within(bar).queryByRole("button", { name: /^Clear filters/ })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,179 +1,56 @@
|
|||||||
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { packetsApi } from "../../api/packets";
|
import { packetsApi } from "../../api/packets";
|
||||||
import type { PacketCard } from "../../api/types";
|
|
||||||
import { useAuth } from "../../auth/useAuth";
|
import { useAuth } from "../../auth/useAuth";
|
||||||
import { Board } from "../../components/Board";
|
import { Board } from "../../components/Board";
|
||||||
import { FindBar, type BoardView } from "../../components/FindBar";
|
import { FindBar } from "../../components/FindBar";
|
||||||
|
import { JobList } from "../../components/JobList";
|
||||||
import { Kpis } from "../../components/Kpis";
|
import { Kpis } from "../../components/Kpis";
|
||||||
import type { UnitPick } from "../../components/PickUnit";
|
|
||||||
import { canEditProjects } from "../../lib/access";
|
import { canEditProjects } from "../../lib/access";
|
||||||
import { useChanged } from "../../lib/changes";
|
import { useChanged } from "../../lib/changes";
|
||||||
import { fmtTime } from "../../lib/format";
|
import { uniqueCards, useFilters, type KpiKey } from "../../lib/filters";
|
||||||
import { STATUSES } from "../../api/types";
|
|
||||||
import { dueInfo, flagClass, jobName, pillClass, statusLabel } from "../../lib/packets";
|
|
||||||
import { useLoad } from "../../lib/useLoad";
|
import { useLoad } from "../../lib/useLoad";
|
||||||
import { byTime, clericalBoards } from "./boards";
|
import { clericalBoards } from "./boards";
|
||||||
import { FromVisualPanel } from "./FromVisualPanel";
|
import { FromVisualPanel } from "./FromVisualPanel";
|
||||||
import { ProjectsSection } from "./ProjectsSection";
|
import { ProjectsSection } from "./ProjectsSection";
|
||||||
import { NewPacketSheet } from "./sheets";
|
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 (
|
|
||||||
<section className="sec" aria-labelledby="jobs-h">
|
|
||||||
<div className="sec-h">
|
|
||||||
<h2 id="jobs-h">Jobs</h2>
|
|
||||||
<span className="ct">
|
|
||||||
{sorted.length} packet{sorted.length === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
<span className="grow" />
|
|
||||||
<label className="f">
|
|
||||||
Sort by
|
|
||||||
<select className="in" value={sort} onChange={(e) => onSort(e.target.value as SortKey)}>
|
|
||||||
{SORTS.map(([k, l]) => (
|
|
||||||
<option key={k} value={k}>
|
|
||||||
{l}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="tw">
|
|
||||||
<table className="joblist" aria-label="Jobs">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Job</th>
|
|
||||||
<th>Operation</th>
|
|
||||||
<th>Department</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Drawing</th>
|
|
||||||
<th>Due</th>
|
|
||||||
<th>Updated</th>
|
|
||||||
<th>Flags</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sorted.map((p) => {
|
|
||||||
const di = dueInfo(p.due_effective);
|
|
||||||
return (
|
|
||||||
<tr key={p.id} className="rowlink" tabIndex={0} onClick={() => navigate(`/packets/${p.id}`)} onKeyDown={(e) => onKey(e, p.id)}>
|
|
||||||
<td>
|
|
||||||
<b>{jobName(p)}</b>
|
|
||||||
<div className="sub">{p.project.name ?? ""}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{p.op ?? ""}
|
|
||||||
<div className="sub">{p.title ?? ""}</div>
|
|
||||||
</td>
|
|
||||||
<td className="small">{p.dept.name}</td>
|
|
||||||
<td>
|
|
||||||
<span className={pillClass(p.status)}>{statusLabel(p.status)}</span>
|
|
||||||
</td>
|
|
||||||
<td className="mono small">
|
|
||||||
{p.drawing ?? ""} {p.rev ? `R${p.rev}` : ""}
|
|
||||||
</td>
|
|
||||||
<td className="small">
|
|
||||||
<span className={di.cls}>{di.txt.replace(/^Due /, "")}</span>
|
|
||||||
</td>
|
|
||||||
<td className="small mono">{fmtTime(p.updated_at)}</td>
|
|
||||||
<td>
|
|
||||||
{p.open_shorts ? <span className="pill st-review">{p.open_shorts} short</span> : null}
|
|
||||||
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{sorted.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="muted">
|
|
||||||
No packets match.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** /clerical: numbers, attention row, find bar, boards or list, then projects. */
|
/** /clerical: numbers, attention row, find bar, boards or list, then projects. */
|
||||||
export function ClericalHome() {
|
export function ClericalHome() {
|
||||||
const { me } = useAuth();
|
const { me, departments } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [q, setQ] = useState("");
|
const flt = useFilters("clerical");
|
||||||
const [query, setQuery] = useState("");
|
const { f, query, queryKey, scope, scopeKey, pick } = flt;
|
||||||
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
|
|
||||||
const [view, setView] = useState<BoardView>("board");
|
|
||||||
const [sort, setSort] = useState<SortKey>("updated");
|
|
||||||
const [drafting, setDrafting] = useState(false);
|
const [drafting, setDrafting] = useState(false);
|
||||||
const [flag, setFlag] = useState("");
|
|
||||||
const projectId = pick.project?.id;
|
|
||||||
const unitId = pick.unit?.id;
|
|
||||||
|
|
||||||
useEffect(() => {
|
// The numbers and the attention row follow the project pick only. The boards follow every filter, and
|
||||||
const t = window.setTimeout(() => setQuery(q.trim()), 250);
|
// take the same answer when nothing else is set.
|
||||||
return () => window.clearTimeout(t);
|
const filtered = queryKey !== scopeKey;
|
||||||
}, [q]);
|
const [scopeLoad, reloadScope] = useLoad((s) => packetsApi.find(scope, s), scopeKey);
|
||||||
|
const [boardLoad, reloadBoard] = useLoad((s) => (filtered ? packetsApi.list(query, s) : Promise.resolve(null)), `${queryKey}|${String(filtered)}`);
|
||||||
const [load, reload] = useLoad(
|
useChanged(
|
||||||
(s) =>
|
() => {
|
||||||
packetsApi.list(
|
reloadScope();
|
||||||
{ ...(query ? { q: query } : {}), ...(flag ? { flag } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) },
|
reloadBoard();
|
||||||
s,
|
},
|
||||||
),
|
{ delayMs: 500 },
|
||||||
`${query}|${flag}|${projectId ?? ""}|${unitId ?? ""}`,
|
|
||||||
);
|
);
|
||||||
useChanged(reload, { delayMs: 500 });
|
|
||||||
|
|
||||||
const all = useMemo(() => (load.state === "ok" ? load.data : []), [load]);
|
const scoped = useMemo(() => (scopeLoad.state === "ok" ? scopeLoad.data.packets : []), [scopeLoad]);
|
||||||
|
// Until the filtered answer lands the boards keep what they show.
|
||||||
|
const all = useMemo(() => (filtered && boardLoad.state === "ok" && boardLoad.data ? boardLoad.data : scoped), [filtered, boardLoad, scoped]);
|
||||||
|
const k = useMemo(() => clericalBoards(scoped), [scoped]);
|
||||||
const b = useMemo(() => clericalBoards(all), [all]);
|
const b = useMemo(() => clericalBoards(all), [all]);
|
||||||
const shown = useMemo(() => {
|
const shown = useMemo(() => {
|
||||||
const ids = new Set<number>();
|
const ids = new Set(uniqueCards(b.columns.map((c) => c.cards)).map((p) => p.id));
|
||||||
const out: PacketCard[] = [];
|
// The answer comes in the server's sort order.
|
||||||
for (const c of b.columns) {
|
return all.filter((p) => ids.has(p.id));
|
||||||
for (const p of c.cards) {
|
}, [b, all]);
|
||||||
if (ids.has(p.id)) continue;
|
const total = useMemo(() => uniqueCards(k.columns.map((c) => c.cards)).length, [k]);
|
||||||
ids.add(p.id);
|
const load = filtered ? boardLoad : scopeLoad;
|
||||||
out.push(p);
|
const ready = load.state === "ok" && scopeLoad.state === "ok";
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}, [b]);
|
|
||||||
const editable = canEditProjects(me?.roles ?? []);
|
const editable = canEditProjects(me?.roles ?? []);
|
||||||
|
const kpi = (key: KpiKey, value: string) => ({ filter: { key, value }, on: flt.isKpiOn(key, value) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -193,42 +70,63 @@ export function ClericalHome() {
|
|||||||
|
|
||||||
<Kpis
|
<Kpis
|
||||||
items={[
|
items={[
|
||||||
{ n: b.cos, label: "Changes to approve", alert: true },
|
{ n: k.cos, label: "Changes to approve", alert: true },
|
||||||
{ n: b.flagged, label: "Flagged", alert: true },
|
{ n: k.flagged, label: "Flagged", alert: true, ...kpi("flag", "any") },
|
||||||
{ n: b.warehouse, label: "With warehouse" },
|
{ n: k.warehouse, label: "With warehouse", ...kpi("status", "warehouse") },
|
||||||
{ n: b.shop, label: "In the shop" },
|
{ n: k.shop, label: "In the shop", ...kpi("status", "shop") },
|
||||||
{ n: b.toClose, label: "Ready to close", alert: true },
|
{ n: k.toClose, label: "Ready to close", alert: true, ...kpi("status", "complete") },
|
||||||
]}
|
]}
|
||||||
|
onFilter={(x) => flt.tapKpi(x.key, x.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{editable ? <FromVisualPanel /> : null}
|
{editable ? <FromVisualPanel /> : null}
|
||||||
|
|
||||||
{b.cos || b.flagged || b.toClose ? (
|
{k.cos || k.flagged || k.toClose ? (
|
||||||
<div className="attn" role="status">
|
<div className="attn" role="status">
|
||||||
{b.cos ? (
|
{k.cos ? (
|
||||||
<div className="callout warn flush">
|
<div className="callout warn flush">
|
||||||
<b>{b.cos === 1 ? "1 change from VISUAL waits" : `${b.cos} changes from VISUAL wait`} for your approval.</b> Nothing goes to the shop
|
<b>{k.cos === 1 ? "1 change from VISUAL waits" : `${k.cos} changes from VISUAL wait`} for your approval.</b> Nothing goes to the shop
|
||||||
packet until you approve it.
|
packet until you approve it.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{b.flagged ? (
|
{k.flagged ? (
|
||||||
<div className="callout warn flush">
|
<div className="callout warn flush">
|
||||||
<b>{b.flagged === 1 ? "1 draft is flagged." : `${b.flagged} drafts are flagged.`}</b> Check {b.flagged === 1 ? "it" : "them"} and send {b.flagged === 1 ? "it" : "them"} on.
|
<b>{k.flagged === 1 ? "1 draft is flagged." : `${k.flagged} drafts are flagged.`}</b> Check {k.flagged === 1 ? "it" : "them"} and send {k.flagged === 1 ? "it" : "them"} on.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{b.toClose ? (
|
{k.toClose ? (
|
||||||
<div className="callout flush">
|
<div className="callout flush">
|
||||||
<b>{b.toClose === 1 ? "1 packet is" : `${b.toClose} packets are`} back from the shop.</b> Close the job in VISUAL, then mark it closed on the packet.
|
<b>{k.toClose === 1 ? "1 packet is" : `${k.toClose} packets are`} back from the shop.</b> Close the job in VISUAL, then mark it closed on the packet.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} view={view} onView={setView} count={load.state === "ok" ? shown.length : null} flag={flag} onFlag={setFlag} />
|
<FindBar
|
||||||
|
f={f}
|
||||||
|
onChange={flt.set}
|
||||||
|
onClear={flt.clear}
|
||||||
|
pick={pick}
|
||||||
|
onPick={flt.setPick}
|
||||||
|
view={flt.view}
|
||||||
|
onView={flt.setView}
|
||||||
|
count={ready ? shown.length : null}
|
||||||
|
total={ready ? total : null}
|
||||||
|
facets={scopeLoad.state === "ok" ? (scopeLoad.data.facets ?? null) : null}
|
||||||
|
depts={departments}
|
||||||
|
screen="clerical"
|
||||||
|
/>
|
||||||
|
|
||||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
{load.state === "loading" || scopeLoad.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||||
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||||
{load.state === "ok" ? view === "board" ? <Board columns={b.columns} /> : <JobList rows={shown} sort={sort} onSort={setSort} /> : null}
|
{scopeLoad.state === "error" && filtered ? <div className="callout warn">{scopeLoad.message}</div> : null}
|
||||||
|
{ready ? (
|
||||||
|
flt.view === "board" ? (
|
||||||
|
<Board columns={b.columns} />
|
||||||
|
) : (
|
||||||
|
<JobList rows={shown} sort={f.sort} onSort={(sort) => flt.set({ sort })} serverSorted />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ProjectsSection />
|
<ProjectsSection />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PmOverview, PmScope, PmTable, PmTables } from "../../api/pm";
|
||||||
|
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||||
|
import { mockFetch } from "../../test/fetchMock";
|
||||||
|
import { makeCard, makeUnit } from "../../test/packets";
|
||||||
|
import { memoryStorage } from "../supervisor/memoryStorage";
|
||||||
|
|
||||||
|
const PAULA = makeMe({
|
||||||
|
user: { sam_account: "pm.paula", display_name: "Paula PM" },
|
||||||
|
roles: ["pm"],
|
||||||
|
screens: ["pm"],
|
||||||
|
groups: ["PS-ProjectMgmt"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = (over: Partial<PmScope> = {}): PmScope => ({
|
||||||
|
label: "All projects",
|
||||||
|
count: 6,
|
||||||
|
period: { value: "90", label: "Last 90 days", start: "2026-07-20T00:00:00Z" },
|
||||||
|
project: null,
|
||||||
|
unit: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = (cells: string[], over: Record<string, unknown> = {}) => ({ cells, pid: null, project_id: null, unit_id: null, lvl: 0, health: null, flag: false, ...over });
|
||||||
|
|
||||||
|
const ATTENTION: PmTable = {
|
||||||
|
key: "attention",
|
||||||
|
title: "Units late or at risk",
|
||||||
|
note: null,
|
||||||
|
head: ["Unit", "Description", "Due", "Left", "Packets", "% done", "Health"],
|
||||||
|
rows: [row(["L670001", "North tank", "2026-10-10", "8 d over", "3", "33%", "Late"], { project_id: 7, unit_id: 12, lvl: 1, health: "late" })] as PmTable["rows"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const OVERVIEW: PmOverview = {
|
||||||
|
scope: scope(),
|
||||||
|
kpis: [
|
||||||
|
{ key: "open", n: 6, label: "Open packets", tab: "aging", alert: false },
|
||||||
|
{ key: "late_units", n: 1, label: "Late units", tab: "projects", alert: true },
|
||||||
|
{ key: "risk_units", n: 0, label: "Units at risk", tab: "projects", alert: true },
|
||||||
|
{ key: "over", n: 2, label: "Packets over target hours", tab: "hours", alert: true },
|
||||||
|
{ key: "lead", n: "4.2 d", label: "Average lead time, created → filed", tab: "aging", alert: false },
|
||||||
|
],
|
||||||
|
summary: ["6 open packets in scope (All projects).", "Late: L670001."],
|
||||||
|
pipeline: [
|
||||||
|
{ key: "draft", label: "Ready to release (Clerical)", count: 2, avg_s: 3600, max_s: 7200, oldest: { pid: 1, job: "L670001 · A" }, slowest: false },
|
||||||
|
{ key: "wh_hold", label: "Warehouse: on hold", count: 1, avg_s: 86_400 * 3, max_s: 86_400 * 3, oldest: { pid: 2, job: "L670001 · B" }, slowest: true },
|
||||||
|
{ key: "qc", label: "At Q.C.", count: 0, avg_s: 0, max_s: 0, oldest: null, slowest: false },
|
||||||
|
],
|
||||||
|
attention: ATTENTION,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROJECTS_HEAD = ["Project / unit", "Name", "Customer", "Due", "Left", "Units", "Packets", "Draft", "Warehouse", "Shop", "Q.C.", "Done", "% done", "Health"];
|
||||||
|
|
||||||
|
function projects(expanded: boolean, picked = false): PmTables {
|
||||||
|
const rows = [row(["L670", "Tank farm", "Acme", "2026-11-01", "14 d", "2", "6", "2", "1", "2", "0", "1", "17%", "Late"], { project_id: 7, health: "late" })];
|
||||||
|
if (expanded || picked) {
|
||||||
|
rows.push(row(["L670001", "North tank", "", "2026-10-10", "8 d over", "", "3", "1", "1", "1", "0", "0", "33%", "Late"], { project_id: 7, unit_id: 12, lvl: 1, health: "late" }));
|
||||||
|
}
|
||||||
|
rows.push(row(["L680", "Skid", "Beta", "", "—", "0", "0", "0", "0", "0", "0", "0", "0%", "No packets yet"], { project_id: 8, health: "empty" }));
|
||||||
|
return {
|
||||||
|
scope: scope(),
|
||||||
|
tables: [{ key: "projects", title: "Projects and units", note: "Tap a project to show its units.", head: PROJECTS_HEAD, rows: rows as PmTable["rows"] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOURS: PmTables = {
|
||||||
|
scope: scope(),
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
key: "hours_over",
|
||||||
|
title: "Packets over target",
|
||||||
|
note: "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the clock of record.",
|
||||||
|
head: ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
|
||||||
|
rows: [row(["L670001 · A", "Stall 1", "Working", "9.0", "8.5", "6.0", "+3.0 h"], { pid: 41, project_id: 7, unit_id: 12, flag: true })] as PmTable["rows"],
|
||||||
|
},
|
||||||
|
{ key: "hours_dept", title: "By department", note: null, head: ["Department", "Packets"], rows: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function routes(extra: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
"/api/auth/me": { body: PAULA },
|
||||||
|
"/api/departments": { body: DEPARTMENTS },
|
||||||
|
"/api/projects": { body: [] },
|
||||||
|
"/api/units": { body: [makeUnit({ id: 12, project_id: 7, number: "L670001", lot: "001" })] },
|
||||||
|
"/api/units/12": { body: makeUnit({ id: 12, project_id: 7, number: "L670001", lot: "001", project: { id: 7, number: "L670", name: "Tank farm" } }) },
|
||||||
|
"/api/packets": { body: { packets: [], facets: { stations: [{ id: 3, name: "Stall 1", dept_code: "03" }], workers: [] } } },
|
||||||
|
"/api/pm/overview": { body: OVERVIEW },
|
||||||
|
"/api/pm/projects": (req: { url: string }) => ({ body: projects(req.url.includes("expand=7"), req.url.includes("project_id=7")) }),
|
||||||
|
"/api/pm/aging": { body: { scope: scope(), pipeline: OVERVIEW.pipeline, tables: [] } },
|
||||||
|
"/api/pm/hours": { body: HOURS },
|
||||||
|
"/api/pm/quality": { body: { scope: scope(), tables: [] } },
|
||||||
|
"/api/pm/find": { body: { scope: scope({ label: "Unit L670001", count: 1 }), packets: [makeCard({ id: 41 })] } },
|
||||||
|
...extra,
|
||||||
|
} as Parameters<typeof mockFetch>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = (fetch: ReturnType<typeof mockFetch>) => fetch.mock.calls.map(([u]) => String(u));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("localStorage", memoryStorage());
|
||||||
|
vi.spyOn(window, "scrollTo").mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PmHome", () => {
|
||||||
|
it("shows the head, the tabs, the KPI tiles, the summary, the pipeline and the units at risk", async () => {
|
||||||
|
mockFetch(routes());
|
||||||
|
renderApp("/pm");
|
||||||
|
expect(await screen.findByRole("heading", { name: "Where every job stands" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Project management · Status and reporting")).toBeInTheDocument();
|
||||||
|
const tabs = screen.getByRole("tablist");
|
||||||
|
expect(within(tabs).getAllByRole("tab").map((t) => t.textContent)).toEqual([
|
||||||
|
"Overview",
|
||||||
|
"Projects & units",
|
||||||
|
"Bottlenecks & aging",
|
||||||
|
"Hours vs target",
|
||||||
|
"Quality & issues",
|
||||||
|
"Find jobs",
|
||||||
|
]);
|
||||||
|
expect(within(tabs).getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
|
await screen.findByText("In plain words");
|
||||||
|
expect(screen.getByText("6 open packets in scope (All projects).")).toBeInTheDocument();
|
||||||
|
const scopeLine = screen.getByTestId("pm-scope");
|
||||||
|
expect(scopeLine).toHaveTextContent("Scope: All projects · 6 packets · history figures: last 90 days · view only");
|
||||||
|
|
||||||
|
expect(screen.getByTestId("pm-kpi-late_units")).toHaveClass("kpi", "hot");
|
||||||
|
expect(screen.getByTestId("pm-kpi-risk_units")).toHaveClass("zero");
|
||||||
|
expect(screen.getByTestId("pm-kpi-open")).not.toHaveClass("hot");
|
||||||
|
expect(screen.getByTestId("pm-kpi-lead")).toHaveTextContent("4.2 d");
|
||||||
|
|
||||||
|
const bars = screen.getByRole("img", { name: "Open packets by step" });
|
||||||
|
const hold = bars.querySelector('[data-step="wh_hold"]');
|
||||||
|
expect(hold).toHaveClass("pmbar", "hot");
|
||||||
|
expect(hold).toHaveTextContent("slowest");
|
||||||
|
expect(bars.querySelector('[data-step="qc"]')).toHaveClass("zero");
|
||||||
|
expect(bars.querySelector('[data-step="draft"]')).toHaveTextContent("2 · avg 1 h");
|
||||||
|
|
||||||
|
const att = screen.getByRole("table", { name: "Units late or at risk" });
|
||||||
|
expect(within(att).getByText("Late")).toHaveClass("pill", "st-review");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a tab from a KPI tile and from Details, and keeps it in ps.pmtab", async () => {
|
||||||
|
const fetch = mockFetch(routes());
|
||||||
|
renderApp("/pm");
|
||||||
|
fireEvent.click(await screen.findByTestId("pm-kpi-over"));
|
||||||
|
expect(await screen.findByRole("heading", { name: "Packets over target" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("tab", { name: "Hours vs target" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(window.localStorage.getItem("ps.pmtab")).toBe("hours");
|
||||||
|
expect(urls(fetch)).toContain("/api/pm/hours?period=90");
|
||||||
|
|
||||||
|
const over = screen.getByRole("table", { name: "Packets over target" });
|
||||||
|
const r = within(over).getByText("+3.0 h").closest("tr");
|
||||||
|
expect(r).toHaveClass("flag", "rowlink");
|
||||||
|
expect(screen.getByText("1 row")).toBeInTheDocument();
|
||||||
|
expect(within(screen.getByRole("table", { name: "By department" })).getByText("Nothing in scope.")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Overview" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Details" }));
|
||||||
|
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/aging?period=90"));
|
||||||
|
expect(screen.getByRole("tab", { name: "Bottlenecks & aging" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a project to its units and drills a unit down to Find jobs", async () => {
|
||||||
|
window.localStorage.setItem("ps.pmtab", "projects");
|
||||||
|
const fetch = mockFetch(routes());
|
||||||
|
renderApp("/pm");
|
||||||
|
const table = await screen.findByRole("table", { name: "Projects and units" });
|
||||||
|
expect(within(table).getByText(/L670/)).toHaveTextContent("▸ L670");
|
||||||
|
expect(within(table).getByText("No packets yet")).toHaveClass("pill", "st-draft");
|
||||||
|
expect(within(table).getByTitle("17% done")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(within(table).getByText(/L670/));
|
||||||
|
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/projects?period=90&expand=7"));
|
||||||
|
const unit = await screen.findByText(/L670001/);
|
||||||
|
expect(unit.closest("tr")).toHaveClass("sub");
|
||||||
|
expect(screen.getByText(/▾ L670$/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(unit.closest("tr") as HTMLElement, { key: "Enter" });
|
||||||
|
await waitFor(() => expect(screen.getByRole("tab", { name: "Find jobs" })).toHaveAttribute("aria-selected", "true"));
|
||||||
|
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/find?unit_id=12&period=90&sort=updated"));
|
||||||
|
expect(await screen.findByRole("table", { name: "Jobs" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("pm-scope")).toHaveTextContent("Scope: Unit L670001 · 1 packet");
|
||||||
|
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
|
||||||
|
expect(window.localStorage.getItem("ps.pmtab")).toBe("find");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drills a unit late or at risk to Find jobs", async () => {
|
||||||
|
const fetch = mockFetch(routes());
|
||||||
|
renderApp("/pm");
|
||||||
|
const att = await screen.findByRole("table", { name: "Units late or at risk" });
|
||||||
|
fireEvent.click(within(att).getByText("L670001"));
|
||||||
|
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/find?unit_id=12&period=90&sort=updated"));
|
||||||
|
await waitFor(() => expect(urls(fetch)).toContain("/api/units/12"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the PDF and CSV links from the scope and the period", async () => {
|
||||||
|
const fetch = mockFetch(routes());
|
||||||
|
const opened = vi.spyOn(window, "open").mockReturnValue(null);
|
||||||
|
const hrefs: string[] = [];
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
|
||||||
|
hrefs.push(this.getAttribute("href") ?? "");
|
||||||
|
});
|
||||||
|
renderApp("/pm");
|
||||||
|
await screen.findByText("In plain words");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Period"), { target: { value: "30" } });
|
||||||
|
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/overview?period=30"));
|
||||||
|
expect(window.localStorage.getItem("ps.pmper")).toBe("30");
|
||||||
|
expect(screen.getByLabelText("Period")).toHaveAttribute("title", "Period for history-based figures");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "PDF report" }));
|
||||||
|
expect(opened).toHaveBeenCalledWith("/api/pm/report.pdf?period=30", "_blank", "noopener");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Excel (CSV) — all" }));
|
||||||
|
expect(hrefs).toEqual(["/api/pm/export.csv?table=all&period=30"]);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Hours vs target" }));
|
||||||
|
const sec = await screen.findByTestId("pmt-hours_over");
|
||||||
|
fireEvent.click(within(sec).getByRole("button", { name: "Excel (CSV)" }));
|
||||||
|
expect(hrefs[1]).toBe("/api/pm/export.csv?table=hours_over&period=30");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Find jobs" }));
|
||||||
|
await screen.findByRole("heading", { name: "Find jobs" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Excel (CSV)" }));
|
||||||
|
expect(hrefs[2]).toBe("/api/pm/export.csv?table=jobs&period=30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the find bar's filters and the sort to the server", async () => {
|
||||||
|
window.localStorage.setItem("ps.pmtab", "find");
|
||||||
|
const fetch = mockFetch(routes());
|
||||||
|
renderApp("/pm");
|
||||||
|
await screen.findByRole("table", { name: "Jobs" });
|
||||||
|
expect(screen.queryByRole("button", { name: "Mine" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("group", { name: "View" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Sort by"), { target: { value: "project" } });
|
||||||
|
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/find?period=90&sort=project"));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "heat 77" } });
|
||||||
|
await waitFor(() => expect(urls(fetch)).toContain("/api/pm/find?q=heat+77&period=90&sort=project"));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Shorts open" }));
|
||||||
|
await waitFor(() => expect(urls(fetch).some((u) => u.startsWith("/api/pm/find?flags=short&q=heat+77"))).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the empty line when no unit is late or at risk", async () => {
|
||||||
|
mockFetch(routes({ "/api/pm/overview": { body: { ...OVERVIEW, attention: { ...ATTENTION, rows: [] } } } }));
|
||||||
|
renderApp("/pm");
|
||||||
|
expect(await screen.findByText("Every unit with packets is on track.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
// /pm (the old homePM): see everything, change nothing, report on it.
|
||||||
|
// The find bar on top scopes every report. Tabs: Overview, Projects & units, Bottlenecks & aging,
|
||||||
|
// Hours vs target, Quality & issues, Find jobs. Every table exports to Excel (CSV); "PDF report" opens the
|
||||||
|
// PESCO production status report built by the server.
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { pmApi, type PmAging, type PmFind, type PmOverview, type PmRow, type PmScope, type PmTabKey, type PmTable, type PmTables } from "../../api/pm";
|
||||||
|
import { projectsApi } from "../../api/projects";
|
||||||
|
import type { ProjectRef, Unit } from "../../api/types";
|
||||||
|
import { useAuth } from "../../auth/useAuth";
|
||||||
|
import type { UnitPick } from "../../components/PickUnit";
|
||||||
|
import { useChanged } from "../../lib/changes";
|
||||||
|
import { useLoad } from "../../lib/useLoad";
|
||||||
|
import { FindBar } from "../../components/FindBar";
|
||||||
|
import { JobList } from "../../components/JobList";
|
||||||
|
import { useFilters, type SortKey } from "../../lib/filters";
|
||||||
|
import { PmBars, PmGrid, PmKpis, PmTableSection } from "./PmTables";
|
||||||
|
import {
|
||||||
|
DEFAULT_PERIOD,
|
||||||
|
expandParam,
|
||||||
|
isPeriod,
|
||||||
|
isPmTab,
|
||||||
|
packetsText,
|
||||||
|
PERIOD_KEY,
|
||||||
|
periodLabel,
|
||||||
|
PM_PERIODS,
|
||||||
|
PM_TABS,
|
||||||
|
rowAct,
|
||||||
|
scopeKey,
|
||||||
|
scopeParams,
|
||||||
|
TAB_KEY,
|
||||||
|
toggleOpen,
|
||||||
|
} from "./pmView";
|
||||||
|
|
||||||
|
type Data =
|
||||||
|
| { tab: "overview"; d: PmOverview }
|
||||||
|
| { tab: "projects"; d: PmTables }
|
||||||
|
| { tab: "aging"; d: PmAging }
|
||||||
|
| { tab: "hours"; d: PmTables }
|
||||||
|
| { tab: "quality"; d: PmTables }
|
||||||
|
| { tab: "find"; d: PmFind };
|
||||||
|
|
||||||
|
function lsRead(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lsWrite(key: string, v: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, v);
|
||||||
|
} catch {
|
||||||
|
// Storage may be blocked. The choice still holds for this page.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts a download of a same-origin URL without leaving the page. */
|
||||||
|
function startDownload(url: string): void {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "";
|
||||||
|
a.rel = "noopener";
|
||||||
|
a.style.display = "none";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTop(): void {
|
||||||
|
try {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
} catch {
|
||||||
|
// Not every host scrolls.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PmHome() {
|
||||||
|
const { departments } = useAuth();
|
||||||
|
const { f, set, clear, pick, setPick, query } = useFilters("pm");
|
||||||
|
const [tab, setTabState] = useState<PmTabKey>(() => {
|
||||||
|
const v = lsRead(TAB_KEY);
|
||||||
|
return isPmTab(v) ? v : "overview";
|
||||||
|
});
|
||||||
|
const [period, setPeriodState] = useState<string>(() => {
|
||||||
|
const v = lsRead(PERIOD_KEY);
|
||||||
|
return isPeriod(v) ? v : DEFAULT_PERIOD;
|
||||||
|
});
|
||||||
|
const [open, setOpen] = useState<Set<number>>(() => new Set());
|
||||||
|
// Bumped on every pick, so a late drill-down answer never overwrites a newer pick.
|
||||||
|
const pickSeq = useRef(0);
|
||||||
|
const pickNow = (p: UnitPick) => {
|
||||||
|
pickSeq.current += 1;
|
||||||
|
setPick(p);
|
||||||
|
};
|
||||||
|
const clearAll = () => {
|
||||||
|
pickSeq.current += 1;
|
||||||
|
clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTab = (t: string) => {
|
||||||
|
if (!isPmTab(t)) return;
|
||||||
|
setTabState(t);
|
||||||
|
lsWrite(TAB_KEY, t);
|
||||||
|
};
|
||||||
|
const setPeriod = (v: string) => {
|
||||||
|
setPeriodState(v);
|
||||||
|
lsWrite(PERIOD_KEY, v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = useMemo(() => scopeParams(query, period), [query, period]);
|
||||||
|
const sKey = scopeKey(params);
|
||||||
|
const expand = expandParam(open);
|
||||||
|
const sort = f.sort;
|
||||||
|
const key = `${tab}|${sKey}|${tab === "projects" ? expand : ""}|${tab === "find" ? sort : ""}`;
|
||||||
|
|
||||||
|
const [load, reload] = useLoad<Data>(async (s) => {
|
||||||
|
switch (tab) {
|
||||||
|
case "overview":
|
||||||
|
return { tab, d: await pmApi.overview(params, s) };
|
||||||
|
case "projects":
|
||||||
|
return { tab, d: await pmApi.projects(params, expand, s) };
|
||||||
|
case "aging":
|
||||||
|
return { tab, d: await pmApi.aging(params, s) };
|
||||||
|
case "hours":
|
||||||
|
return { tab, d: await pmApi.hours(params, s) };
|
||||||
|
case "quality":
|
||||||
|
return { tab, d: await pmApi.quality(params, s) };
|
||||||
|
default:
|
||||||
|
return { tab: "find", d: await pmApi.find(params, sort, s) };
|
||||||
|
}
|
||||||
|
}, key);
|
||||||
|
useChanged(reload, { delayMs: 500 });
|
||||||
|
|
||||||
|
const facetKey = `${params.project_id ?? ""}|${params.unit_id ?? ""}`;
|
||||||
|
const [facetLoad] = useLoad((s) => pmApi.facets(params, s), facetKey);
|
||||||
|
const facets = facetLoad.state === "ok" ? facetLoad.data : null;
|
||||||
|
|
||||||
|
// A tab change keeps the last answer on screen only when it is for the same tab.
|
||||||
|
const data = load.state === "ok" && load.data.tab === tab ? load.data : null;
|
||||||
|
const scope: PmScope | null = data ? data.d.scope : null;
|
||||||
|
const depts = departments.filter((d) => d.code !== "CL");
|
||||||
|
|
||||||
|
const csv = (table: string) => startDownload(pmApi.csvUrl(table, params));
|
||||||
|
const pdf = () => window.open(pmApi.pdfUrl(params), "_blank", "noopener");
|
||||||
|
|
||||||
|
/** Scope the find bar to a project or unit and go to Find jobs (the old pmProj drill down). */
|
||||||
|
const drill = (projectId: number, unitId: number | null, rows: readonly PmRow[]) => {
|
||||||
|
const projRow = rows.find((r) => r.lvl === 0 && r.unit_id === null && r.project_id === projectId);
|
||||||
|
const known = pick.project?.id === projectId ? pick.project : scope?.project?.id === projectId ? scope.project : null;
|
||||||
|
const project: ProjectRef = known ?? { id: projectId, number: projRow?.cells[0] ?? "", name: projRow?.cells[1] || null };
|
||||||
|
const unitRow = unitId === null ? null : rows.find((r) => r.unit_id === unitId);
|
||||||
|
const unit: Unit | null =
|
||||||
|
unitId === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
id: unitId,
|
||||||
|
project_id: projectId,
|
||||||
|
number: unitRow?.cells[0] ?? "",
|
||||||
|
lot: project.number && unitRow?.cells[0]?.startsWith(project.number) ? unitRow.cells[0].slice(project.number.length) : "",
|
||||||
|
name: unitRow?.cells[1] || null,
|
||||||
|
due: null,
|
||||||
|
due_effective: null,
|
||||||
|
status: "open",
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
pickNow({ project, unit });
|
||||||
|
const seq = pickSeq.current;
|
||||||
|
const refine = (p: UnitPick) => {
|
||||||
|
if (pickSeq.current === seq) setPick(p);
|
||||||
|
};
|
||||||
|
setTab("find");
|
||||||
|
toTop();
|
||||||
|
// Fill in the customer, PO and due date for the find bar's summary line.
|
||||||
|
if (unitId !== null) {
|
||||||
|
projectsApi
|
||||||
|
.unit(unitId)
|
||||||
|
.then((u) => {
|
||||||
|
const p: ProjectRef = u.project ?? project;
|
||||||
|
refine({ project: { ...p }, unit: { ...u, project_id: p.id } });
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
projectsApi
|
||||||
|
.get(projectId)
|
||||||
|
.then((p) => refine({ project: { id: p.id, number: p.number, name: p.name, customer: p.customer, po: p.po, status: p.status, due: p.due }, unit: null }))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRow = (t: PmTable) => (r: PmRow) => {
|
||||||
|
const a = rowAct(t, r, pick.project?.id ?? null);
|
||||||
|
if (!a || a.kind === "packet") return false;
|
||||||
|
if (a.kind === "toggle") setOpen((o) => toggleOpen(o, a.projectId));
|
||||||
|
else drill(a.projectId, a.unitId, t.rows);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const canAct = (t: PmTable) => (r: PmRow) => rowAct(t, r, pick.project?.id ?? null) !== null;
|
||||||
|
|
||||||
|
const section = (t: PmTable) => (
|
||||||
|
<PmTableSection
|
||||||
|
key={t.key}
|
||||||
|
t={t}
|
||||||
|
onCsv={csv}
|
||||||
|
onRow={onRow(t)}
|
||||||
|
canAct={canAct(t)}
|
||||||
|
{...(t.key === "projects" ? { opened: new Set([...open, ...(pick.project ? [pick.project.id] : [])]) } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="phead">
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="eyebrow">Project management · Status and reporting</div>
|
||||||
|
<h1>Where every job stands</h1>
|
||||||
|
</div>
|
||||||
|
<div className="pacts">
|
||||||
|
<label className="f pmper">
|
||||||
|
<span className="sr">Period</span>
|
||||||
|
<select className="in" title="Period for history-based figures" value={period} onChange={(e) => setPeriod(e.target.value)}>
|
||||||
|
{PM_PERIODS.map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn pri" onClick={pdf}>
|
||||||
|
PDF report
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={() => csv("all")}>
|
||||||
|
Excel (CSV) — all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="pmtabs tabs" role="tablist" aria-label="Reports">
|
||||||
|
{PM_TABS.map(([k, l]) => (
|
||||||
|
<button key={k} type="button" role="tab" aria-selected={tab === k} onClick={() => setTab(k)}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<FindBar
|
||||||
|
f={f}
|
||||||
|
onChange={set}
|
||||||
|
onClear={clearAll}
|
||||||
|
pick={pick}
|
||||||
|
onPick={pickNow}
|
||||||
|
count={scope ? scope.count : null}
|
||||||
|
facets={facets}
|
||||||
|
depts={depts}
|
||||||
|
screen="pm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="small muted pmscope" data-testid="pm-scope">
|
||||||
|
Scope: <b>{scope?.label ?? "…"}</b> · {packetsText(scope?.count ?? 0)} · history figures: {(scope?.period.label || periodLabel(period)).toLowerCase()} · view only
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{load.state === "loading" || (load.state === "ok" && !data) ? <div className="empty slim">Loading</div> : null}
|
||||||
|
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||||
|
|
||||||
|
{data?.tab === "overview" ? <Overview d={data.d} onTab={setTab} onRow={onRow(data.d.attention)} /> : null}
|
||||||
|
{data?.tab === "projects" ? data.d.tables.map(section) : null}
|
||||||
|
{data?.tab === "aging" ? (
|
||||||
|
<>
|
||||||
|
<section className="sec">
|
||||||
|
<div className="panel">
|
||||||
|
<PmBars steps={data.d.pipeline} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{data.d.tables.map(section)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{data?.tab === "hours" || data?.tab === "quality" ? data.d.tables.map(section) : null}
|
||||||
|
{data?.tab === "find" ? (
|
||||||
|
<>
|
||||||
|
<section className="sec">
|
||||||
|
<div className="sec-h">
|
||||||
|
<h2>Find jobs</h2>
|
||||||
|
<span className="grow" />
|
||||||
|
<button type="button" className="btn sm ghost" onClick={() => csv("jobs")}>
|
||||||
|
Excel (CSV)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<JobList rows={data.d.packets} sort={sort} onSort={(k: SortKey) => set({ sort: k })} serverSorted />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Overview({ d, onTab, onRow }: { d: PmOverview; onTab: (t: string) => void; onRow: (r: PmRow) => boolean }) {
|
||||||
|
const att = d.attention;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PmKpis items={d.kpis} onTab={onTab} />
|
||||||
|
<section className="sec">
|
||||||
|
<div className="panel pmsum" data-testid="pm-summary">
|
||||||
|
<h3>In plain words</h3>
|
||||||
|
<ul>
|
||||||
|
{d.summary.map((l, i) => (
|
||||||
|
<li key={i}>{l}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="sec" aria-labelledby="pm-pipe-h">
|
||||||
|
<div className="sec-h">
|
||||||
|
<h2 id="pm-pipe-h">Pipeline</h2>
|
||||||
|
<span className="ct">open packets by step · hover a bar for time in step</span>
|
||||||
|
<span className="grow" />
|
||||||
|
<button type="button" className="btn sm ghost" onClick={() => onTab("aging")}>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="panel">
|
||||||
|
<PmBars steps={d.pipeline} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="sec" aria-labelledby="pm-att-h">
|
||||||
|
<div className="sec-h">
|
||||||
|
<h2 id="pm-att-h">{att.title || "Units late or at risk"}</h2>
|
||||||
|
<span className="ct">{att.rows.length}</span>
|
||||||
|
</div>
|
||||||
|
{att.rows.length ? <PmGrid t={att} onRow={onRow} label="Units late or at risk" /> : <div className="empty slim">Every unit with packets is on track.</div>}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// The pieces of the Project Management screen: the KPI tiles, the pipeline bars and the report tables.
|
||||||
|
// The old pm.js pmTable, pmBars and the overview markup.
|
||||||
|
import type { KeyboardEvent } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import type { PmKpi, PmRow, PmStep, PmTable } from "../../api/pm";
|
||||||
|
import { barMax, barTitle, barWidth, colOf, fmtDays, healthLabel, healthPill, kpiClass, kpiParts, pctOf, rowsText, slowestKey, unitCount } from "./pmView";
|
||||||
|
|
||||||
|
export function HealthPill({ h }: { h: string }) {
|
||||||
|
return <span className={healthPill(h)}>{healthLabel(h)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The overview tiles. Each one opens its tab. */
|
||||||
|
export function PmKpis({ items, onTab }: { items: PmKpi[]; onTab: (tab: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="kpis pmk" role="group" aria-label="Numbers">
|
||||||
|
{items.map((k) => {
|
||||||
|
const { main, unit } = kpiParts(k.n);
|
||||||
|
return (
|
||||||
|
<button key={k.key || k.label} type="button" className={kpiClass(k.n, k.alert)} data-testid={`pm-kpi-${k.key}`} aria-label={`${k.label}: ${String(k.n)}`} onClick={() => onTab(k.tab)}>
|
||||||
|
<div className="n">
|
||||||
|
{main}
|
||||||
|
{unit ? <small>{unit}</small> : null}
|
||||||
|
</div>
|
||||||
|
<div className="l">{k.label}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pmBars: open packets per step, the slowest step in pink. */
|
||||||
|
export function PmBars({ steps }: { steps: PmStep[] }) {
|
||||||
|
const max = barMax(steps);
|
||||||
|
const slow = slowestKey(steps);
|
||||||
|
return (
|
||||||
|
<div className="pmbars" role="img" aria-label="Open packets by step" data-testid="pm-bars">
|
||||||
|
{steps.map((s) => {
|
||||||
|
const hot = s.key === slow;
|
||||||
|
const cls = ["pmbar", hot ? "hot" : "", s.count ? "" : "zero"].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<div key={s.key} className={cls} title={barTitle(s)} data-step={s.key}>
|
||||||
|
<span className="lb">{s.label}</span>
|
||||||
|
<span className="tr">
|
||||||
|
<i style={{ width: `${barWidth(s.count, max)}%` }} />
|
||||||
|
</span>
|
||||||
|
<span className="v">
|
||||||
|
<b>{s.count}</b>
|
||||||
|
{s.count ? <span className="muted"> · avg {fmtDays(s.avg_s)}</span> : null}
|
||||||
|
{hot ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<span className="slowtag">slowest</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GridProps {
|
||||||
|
t: PmTable;
|
||||||
|
/** The project table draws arrows on project rows and indents unit rows. */
|
||||||
|
opened?: ReadonlySet<number>;
|
||||||
|
/** A row without a packet: open, drill or toggle. Returns false when the row does nothing. */
|
||||||
|
onRow?: (r: PmRow) => boolean;
|
||||||
|
canAct?: (r: PmRow) => boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The table itself: .tw > table.joblist.pmtable. */
|
||||||
|
export function PmGrid({ t, opened, onRow, canAct, label }: GridProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isProj = t.key === "projects";
|
||||||
|
const hCol = colOf(t, "Health");
|
||||||
|
const pCol = colOf(t, "% done");
|
||||||
|
const act = (r: PmRow) => {
|
||||||
|
if (r.pid) navigate(`/packets/${r.pid}`);
|
||||||
|
else onRow?.(r);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent, r: PmRow) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
act(r);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="tw">
|
||||||
|
<table className="joblist pmtable" aria-label={label ?? t.title}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{t.head.map((h, i) => (
|
||||||
|
<th key={`${i}-${h}`}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{t.rows.map((r, ri) => {
|
||||||
|
const live = !!r.pid || (!!onRow && (canAct ? canAct(r) : true));
|
||||||
|
const cls = [r.flag ? "flag" : "", r.lvl ? "sub" : "", live ? "rowlink" : ""].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${ri}-${r.pid ?? ""}-${r.project_id ?? ""}-${r.unit_id ?? ""}`}
|
||||||
|
className={cls || undefined}
|
||||||
|
{...(live ? { tabIndex: 0, onClick: () => act(r), onKeyDown: (e: KeyboardEvent) => onKey(e, r) } : {})}
|
||||||
|
data-pid={r.pid ?? undefined}
|
||||||
|
data-unit-id={r.unit_id ?? undefined}
|
||||||
|
data-project-id={r.project_id ?? undefined}
|
||||||
|
>
|
||||||
|
{r.cells.map((c, i) => {
|
||||||
|
if (i === hCol && r.health) {
|
||||||
|
return (
|
||||||
|
<td key={i}>
|
||||||
|
<HealthPill h={r.health} />
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (i === pCol) {
|
||||||
|
return (
|
||||||
|
<td key={i} style={{ minWidth: 120 }}>
|
||||||
|
<div className="pbar" title={`${c} done`}>
|
||||||
|
<i className="c" style={{ width: `${pctOf(c)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="small">{c}</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isProj && i === 0) {
|
||||||
|
const arrow = r.lvl ? " └ " : r.project_id !== null && opened ? (opened.has(r.project_id) ? "▾ " : unitCount(t, r) > 0 ? "▸ " : "") : "";
|
||||||
|
return (
|
||||||
|
<td key={i} className="mono">
|
||||||
|
<b>
|
||||||
|
{arrow}
|
||||||
|
{c}
|
||||||
|
</b>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (i === 0 && hCol >= 0) {
|
||||||
|
return (
|
||||||
|
<td key={i} className="mono">
|
||||||
|
<b>{c}</b>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <td key={i}>{c}</td>;
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{t.rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={Math.max(1, t.head.length)} className="muted">
|
||||||
|
Nothing in scope.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pmTable: one report table in its section, with its own Excel (CSV) button. */
|
||||||
|
export function PmTableSection({ t, onCsv, ...grid }: Omit<GridProps, "t"> & { t: PmTable; onCsv: (key: string) => void }) {
|
||||||
|
const id = `pmt-${t.key}`;
|
||||||
|
return (
|
||||||
|
<section className="sec pmt" aria-labelledby={id} data-testid={id}>
|
||||||
|
<div className="sec-h">
|
||||||
|
<h2 id={id}>{t.title}</h2>
|
||||||
|
<span className="ct">{rowsText(t.rows.length)}</span>
|
||||||
|
<span className="grow" />
|
||||||
|
<button type="button" className="btn sm ghost" onClick={() => onCsv(t.key)}>
|
||||||
|
Excel (CSV)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{t.note ? <div className="small muted pmnote">{t.note}</div> : null}
|
||||||
|
<PmGrid t={t} {...grid} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { PmRow, PmStep } from "../../api/pm";
|
||||||
|
import { csvUrl, pdfUrl } from "../../api/pm";
|
||||||
|
import {
|
||||||
|
barMax,
|
||||||
|
barTitle,
|
||||||
|
barWidth,
|
||||||
|
expandParam,
|
||||||
|
fmtDays,
|
||||||
|
healthLabel,
|
||||||
|
healthPill,
|
||||||
|
isPeriod,
|
||||||
|
isPmTab,
|
||||||
|
kpiClass,
|
||||||
|
kpiParts,
|
||||||
|
pctOf,
|
||||||
|
periodLabel,
|
||||||
|
pmFlags,
|
||||||
|
rowAct,
|
||||||
|
scopeKey,
|
||||||
|
scopeParams,
|
||||||
|
slowestKey,
|
||||||
|
toggleOpen,
|
||||||
|
unitCount,
|
||||||
|
} from "./pmView";
|
||||||
|
|
||||||
|
const row = (over: Partial<PmRow> = {}): PmRow => ({ cells: [], pid: null, project_id: null, unit_id: null, lvl: 0, health: null, flag: false, ...over });
|
||||||
|
const step = (over: Partial<PmStep> = {}): PmStep => ({ key: "ready", label: "Ready to assign", count: 0, avg_s: 0, max_s: 0, oldest: null, slowest: false, ...over });
|
||||||
|
const PROJ = { key: "projects", head: ["Project / unit", "Name", "Customer", "Due", "Left", "Units", "Packets"] };
|
||||||
|
|
||||||
|
describe("scopeParams", () => {
|
||||||
|
it("puts the scope in a fixed order and leaves out empty values", () => {
|
||||||
|
const p = scopeParams({ q: "heat 123", unit_id: 12, project_id: undefined, status: "shop", flags: "co,short", flag: "", dept: "03" }, "30");
|
||||||
|
expect(Object.keys(p)).toEqual(["unit_id", "dept", "status", "flags", "q", "period"]);
|
||||||
|
expect(p).toEqual({ unit_id: "12", dept: "03", status: "shop", flags: "co,short", q: "heat 123", period: "30" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops Mine, which PM ignores, and falls back to 90 days", () => {
|
||||||
|
expect(scopeParams({ flags: "mine" }, "bogus")).toEqual({ period: "90" });
|
||||||
|
expect(pmFlags(["co", "mine", "week"])).toBe("co,week");
|
||||||
|
expect(pmFlags(null)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a stable key and the export URLs", () => {
|
||||||
|
const p = scopeParams({ project_id: 7, station_id: 3, welder_id: "u-1" }, "all");
|
||||||
|
expect(scopeKey(p)).toBe("project_id=7&station_id=3&welder_id=u-1&period=all");
|
||||||
|
expect(csvUrl("all", p)).toBe("/api/pm/export.csv?table=all&project_id=7&station_id=3&welder_id=u-1&period=all");
|
||||||
|
expect(csvUrl("jobs", { period: "90" })).toBe("/api/pm/export.csv?table=jobs&period=90");
|
||||||
|
expect(pdfUrl(p)).toBe("/api/pm/report.pdf?project_id=7&station_id=3&welder_id=u-1&period=all");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("labels", () => {
|
||||||
|
it("knows the periods and the tabs", () => {
|
||||||
|
expect(periodLabel("365")).toBe("Last 12 months");
|
||||||
|
expect(periodLabel("x")).toBe("Last 90 days");
|
||||||
|
expect(isPeriod("all")).toBe(true);
|
||||||
|
expect(isPeriod("7")).toBe(false);
|
||||||
|
expect(isPmTab("find")).toBe(true);
|
||||||
|
expect(isPmTab("board")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps health to the old pill classes", () => {
|
||||||
|
expect(healthPill("ok")).toBe("pill st-in_progress");
|
||||||
|
expect(healthPill("risk")).toBe("pill st-warehouse");
|
||||||
|
expect(healthPill("late")).toBe("pill st-review");
|
||||||
|
expect(healthPill("done")).toBe("pill st-filed");
|
||||||
|
expect(healthPill("empty")).toBe("pill st-draft");
|
||||||
|
expect(healthLabel("empty")).toBe("No packets yet");
|
||||||
|
expect(healthLabel("late")).toBe("Late");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats durations as the old fmtDays", () => {
|
||||||
|
expect(fmtDays(0)).toBe("0 h");
|
||||||
|
expect(fmtDays(5 * 3600 + 1200)).toBe("5 h");
|
||||||
|
expect(fmtDays(86_400 * 2.25)).toBe("2.3 d");
|
||||||
|
expect(fmtDays(86_400 * 14.6)).toBe("15 d");
|
||||||
|
expect(fmtDays(null)).toBe("0 h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives KPI classes and splits the day suffix", () => {
|
||||||
|
expect(kpiClass(3, true)).toBe("kpi hot");
|
||||||
|
expect(kpiClass(0, true)).toBe("kpi zero");
|
||||||
|
expect(kpiClass(4, false)).toBe("kpi");
|
||||||
|
expect(kpiParts("4.2 d")).toEqual({ main: "4.2", unit: " d" });
|
||||||
|
expect(kpiParts("—")).toEqual({ main: "—", unit: "" });
|
||||||
|
expect(kpiParts(12)).toEqual({ main: "12", unit: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bars", () => {
|
||||||
|
it("scales to the largest count", () => {
|
||||||
|
const steps = [step({ count: 2 }), step({ key: "qc", count: 8 })];
|
||||||
|
expect(barMax(steps)).toBe(8);
|
||||||
|
expect(barMax([])).toBe(1);
|
||||||
|
expect(barWidth(2, 8)).toBe(25);
|
||||||
|
expect(barWidth(0, 0)).toBe(0);
|
||||||
|
expect(barWidth(9, 8)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the slowest step: the server's flag first, else the highest average with packets", () => {
|
||||||
|
expect(slowestKey([step({ key: "a", count: 1, avg_s: 10 }), step({ key: "b", count: 1, avg_s: 5, slowest: true })])).toBe("b");
|
||||||
|
expect(slowestKey([step({ key: "a", count: 1, avg_s: 10 }), step({ key: "b", count: 0, avg_s: 99 })])).toBe("a");
|
||||||
|
expect(slowestKey([step({ count: 0 })])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("titles a bar", () => {
|
||||||
|
expect(barTitle(step({ count: 1, avg_s: 7200, max_s: 86_400 * 3 }))).toBe("Ready to assign: 1 packet, average 2 h in this step, longest 3.0 d");
|
||||||
|
expect(barTitle(step({ count: 0 }))).toBe("Ready to assign: 0 packets");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the % done text", () => {
|
||||||
|
expect(pctOf("62%")).toBe(62);
|
||||||
|
expect(pctOf("")).toBe(0);
|
||||||
|
expect(pctOf("140%")).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rows", () => {
|
||||||
|
it("opens a packet row", () => {
|
||||||
|
expect(rowAct(PROJ, row({ pid: 5, project_id: 7 }), null)).toEqual({ kind: "packet", pid: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles a project with units, drills a project without, and drills the picked project", () => {
|
||||||
|
const withUnits = row({ project_id: 7, cells: ["L670", "Tank", "", "", "", "2", "4"] });
|
||||||
|
const noUnits = row({ project_id: 8, cells: ["L680", "Skid", "", "", "", "0", "0"] });
|
||||||
|
expect(unitCount(PROJ, withUnits)).toBe(2);
|
||||||
|
expect(rowAct(PROJ, withUnits, null)).toEqual({ kind: "toggle", projectId: 7 });
|
||||||
|
expect(rowAct(PROJ, withUnits, 7)).toEqual({ kind: "drill", projectId: 7, unitId: null });
|
||||||
|
expect(rowAct(PROJ, noUnits, null)).toEqual({ kind: "drill", projectId: 8, unitId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drills a unit row, and leaves the loose packets row alone", () => {
|
||||||
|
expect(rowAct(PROJ, row({ project_id: 7, unit_id: 12, lvl: 1 }), null)).toEqual({ kind: "drill", projectId: 7, unitId: 12 });
|
||||||
|
expect(rowAct({ key: "attention", head: ["Unit"] }, row({ project_id: 7, unit_id: 12 }), null)).toEqual({ kind: "drill", projectId: 7, unitId: 12 });
|
||||||
|
expect(rowAct(PROJ, row({ cells: ["Packets with no project set up"] }), null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the expand set", () => {
|
||||||
|
let open = toggleOpen(new Set(), 9);
|
||||||
|
open = toggleOpen(open, 7);
|
||||||
|
expect(expandParam(open)).toBe("7,9");
|
||||||
|
expect(expandParam(toggleOpen(open, 9))).toBe("7");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Pure helpers for the Project Management screen. The old pm.js names are noted where they came from.
|
||||||
|
import type { HealthKey, PmPeriod, PmQuery, PmRow, PmStep, PmTabKey, PmTable } from "../../api/pm";
|
||||||
|
|
||||||
|
/** PM_TABS. */
|
||||||
|
export const PM_TABS: ReadonlyArray<[PmTabKey, string]> = [
|
||||||
|
["overview", "Overview"],
|
||||||
|
["projects", "Projects & units"],
|
||||||
|
["aging", "Bottlenecks & aging"],
|
||||||
|
["hours", "Hours vs target"],
|
||||||
|
["quality", "Quality & issues"],
|
||||||
|
["find", "Find jobs"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** PM_PERIODS. */
|
||||||
|
export const PM_PERIODS: ReadonlyArray<[PmPeriod, string]> = [
|
||||||
|
["30", "Last 30 days"],
|
||||||
|
["90", "Last 90 days"],
|
||||||
|
["365", "Last 12 months"],
|
||||||
|
["all", "All time"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TAB_KEY = "ps.pmtab";
|
||||||
|
export const PERIOD_KEY = "ps.pmper";
|
||||||
|
export const DEFAULT_PERIOD: PmPeriod = "90";
|
||||||
|
|
||||||
|
export function isPmTab(v: unknown): v is PmTabKey {
|
||||||
|
return typeof v === "string" && PM_TABS.some(([k]) => k === v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPeriod(v: unknown): v is PmPeriod {
|
||||||
|
return typeof v === "string" && PM_PERIODS.some(([k]) => k === v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Last 90 days". */
|
||||||
|
export function periodLabel(v: string): string {
|
||||||
|
return PM_PERIODS.find(([k]) => k === v)?.[1] ?? "Last 90 days";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HEALTH: label and pill class. */
|
||||||
|
export const HEALTH: Record<HealthKey, [string, string]> = {
|
||||||
|
ok: ["On track", "st-in_progress"],
|
||||||
|
risk: ["At risk", "st-warehouse"],
|
||||||
|
late: ["Late", "st-review"],
|
||||||
|
done: ["Complete", "st-filed"],
|
||||||
|
empty: ["No packets yet", "st-draft"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function healthLabel(h: string): string {
|
||||||
|
return (HEALTH as Record<string, [string, string]>)[h]?.[0] ?? h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hPill: "pill st-...". */
|
||||||
|
export function healthPill(h: string): string {
|
||||||
|
return `pill ${(HEALTH as Record<string, [string, string]>)[h]?.[1] ?? "st-draft"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the scope is built from: the find bar's query (PacketListQuery or the like). */
|
||||||
|
export interface ScopeSource {
|
||||||
|
project_id?: number | string | null | undefined;
|
||||||
|
unit_id?: number | string | null | undefined;
|
||||||
|
dept?: string | null | undefined;
|
||||||
|
status?: string | null | undefined;
|
||||||
|
station_id?: number | string | null | undefined;
|
||||||
|
welder_id?: string | null | undefined;
|
||||||
|
flags?: string | readonly string[] | null | undefined;
|
||||||
|
flag?: string | null | undefined;
|
||||||
|
q?: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The comma list of quick flags without `mine` (PM ignores it). */
|
||||||
|
export function pmFlags(flags: ScopeSource["flags"]): string {
|
||||||
|
const list = Array.isArray(flags) ? flags : typeof flags === "string" ? flags.split(",") : [];
|
||||||
|
return list
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s && s !== "mine")
|
||||||
|
.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The scope params every PM call takes, in a fixed order, empty values left out. */
|
||||||
|
export function scopeParams(src: ScopeSource, period: string): PmQuery {
|
||||||
|
const out: PmQuery = {};
|
||||||
|
const put = (k: string, v: unknown) => {
|
||||||
|
if (v === undefined || v === null) return;
|
||||||
|
const s = String(v).trim();
|
||||||
|
if (s) out[k] = s;
|
||||||
|
};
|
||||||
|
// A picked unit already names its project; the server takes either.
|
||||||
|
put("project_id", src.project_id);
|
||||||
|
put("unit_id", src.unit_id);
|
||||||
|
put("dept", src.dept);
|
||||||
|
put("status", src.status);
|
||||||
|
put("station_id", src.station_id);
|
||||||
|
put("welder_id", src.welder_id);
|
||||||
|
put("flags", pmFlags(src.flags));
|
||||||
|
put("flag", src.flag);
|
||||||
|
put("q", src.q);
|
||||||
|
put("period", isPeriod(period) ? period : DEFAULT_PERIOD);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable key for reloading when the scope changes. */
|
||||||
|
export function scopeKey(q: PmQuery): string {
|
||||||
|
return Object.entries(q)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join("&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fmtDays(seconds): under a day "<h> h", else "<d> d", one decimal under 10 days. */
|
||||||
|
export function fmtDays(seconds: number | null | undefined): string {
|
||||||
|
const s = Number(seconds) || 0;
|
||||||
|
const days = s / 86_400;
|
||||||
|
if (days < 1) return `${Math.max(0, Math.round(s / 3600))} h`;
|
||||||
|
return days < 10 ? `${days.toFixed(1)} d` : `${Math.round(days)} d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The largest count, at least 1, so an empty pipeline draws empty bars. */
|
||||||
|
export function barMax(steps: readonly Pick<PmStep, "count">[]): number {
|
||||||
|
return Math.max(1, ...steps.map((s) => Number(s.count) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bar's width in percent of the largest count. */
|
||||||
|
export function barWidth(count: number, max: number): number {
|
||||||
|
const m = Math.max(1, max);
|
||||||
|
return Math.max(0, Math.min(100, ((Number(count) || 0) / m) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slowest step: the server's flag, else the highest average among steps with packets. */
|
||||||
|
export function slowestKey(steps: readonly PmStep[]): string | null {
|
||||||
|
const flagged = steps.find((s) => s.slowest);
|
||||||
|
if (flagged) return flagged.key;
|
||||||
|
const best = steps.filter((s) => s.count > 0).sort((a, b) => b.avg_s - a.avg_s)[0];
|
||||||
|
return best ? best.key : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hover text on a bar. */
|
||||||
|
export function barTitle(s: PmStep): string {
|
||||||
|
const base = `${s.label}: ${s.count} packet${s.count === 1 ? "" : "s"}`;
|
||||||
|
return s.count ? `${base}, average ${fmtDays(s.avg_s)} in this step, longest ${fmtDays(s.max_s)}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "% done" text to a bar width: "62%" gives 62. */
|
||||||
|
export function pctOf(cell: string | undefined): number {
|
||||||
|
const n = parseInt(cell ?? "", 10);
|
||||||
|
return Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "n rows". */
|
||||||
|
export function rowsText(n: number): string {
|
||||||
|
return `${n} row${n === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "n packets". */
|
||||||
|
export function packetsText(n: number): string {
|
||||||
|
return `${n} packet${n === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The column index of a head label, or -1. */
|
||||||
|
export function colOf(t: Pick<PmTable, "head">, name: string): number {
|
||||||
|
return t.head.indexOf(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The unit count on a project row of the projects table (the `Units` column). */
|
||||||
|
export function unitCount(t: Pick<PmTable, "head">, r: PmRow): number {
|
||||||
|
const i = colOf(t, "Units");
|
||||||
|
return i < 0 ? 0 : parseInt(r.cells[i] ?? "", 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens or closes one project in the expand set. Returns a new set. */
|
||||||
|
export function toggleOpen(open: ReadonlySet<number>, id: number): Set<number> {
|
||||||
|
const next = new Set(open);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `expand` param: the opened project ids, sorted, comma separated. */
|
||||||
|
export function expandParam(open: ReadonlySet<number>): string {
|
||||||
|
return [...open].sort((a, b) => a - b).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RowAct = { kind: "packet"; pid: number } | { kind: "toggle"; projectId: number } | { kind: "drill"; projectId: number; unitId: number | null } | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a tap on a row does (the old pmProj). A packet row opens the packet. A project row with units
|
||||||
|
* toggles them, unless it is the project the find bar already picked (its units show already); then, and
|
||||||
|
* for a project without units or a unit row, the find bar is scoped to it and the tab goes to Find jobs.
|
||||||
|
*/
|
||||||
|
export function rowAct(t: Pick<PmTable, "key" | "head">, r: PmRow, pickedProjectId: number | null): RowAct {
|
||||||
|
if (r.pid) return { kind: "packet", pid: r.pid };
|
||||||
|
if (r.project_id === null) return null;
|
||||||
|
if (r.unit_id !== null || r.lvl === 1) return { kind: "drill", projectId: r.project_id, unitId: r.unit_id };
|
||||||
|
if (t.key === "projects" && unitCount(t, r) > 0 && pickedProjectId !== r.project_id) return { kind: "toggle", projectId: r.project_id };
|
||||||
|
return { kind: "drill", projectId: r.project_id, unitId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The KPI number as text. A trailing " d" is split off for the small suffix, as the old tile did. */
|
||||||
|
export function kpiParts(n: number | string): { main: string; unit: string } {
|
||||||
|
const s = String(n);
|
||||||
|
const m = /^(.*\d)\s*d$/.exec(s);
|
||||||
|
return m ? { main: m[1] ?? s, unit: " d" } : { main: s, unit: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A KPI tile's classes: hot when an alert above zero, zero when zero. */
|
||||||
|
export function kpiClass(n: number | string, alert: boolean): string {
|
||||||
|
const zero = String(n) === "0";
|
||||||
|
return ["kpi", alert && !zero ? "hot" : "", zero ? "zero" : ""].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
|
||||||
import { mockFetch } from "../../test/fetchMock";
|
import { mockFetch } from "../../test/fetchMock";
|
||||||
|
import { memoryStorage } from "../supervisor/memoryStorage";
|
||||||
import { makeHome, QUINN } from "./fixtures";
|
import { makeHome, QUINN } from "./fixtures";
|
||||||
|
|
||||||
function base(home = makeHome(), me = QUINN) {
|
function base(home = makeHome(), me = QUINN) {
|
||||||
@@ -13,6 +14,10 @@ function base(home = makeHome(), me = QUINN) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("localStorage", memoryStorage());
|
||||||
|
});
|
||||||
|
|
||||||
describe("QcHome", () => {
|
describe("QcHome", () => {
|
||||||
it("shows the page head, the KPIs, the three boards and the folder grid", async () => {
|
it("shows the page head, the KPIs, the three boards and the folder grid", async () => {
|
||||||
mockFetch(base());
|
mockFetch(base());
|
||||||
@@ -43,14 +48,26 @@ describe("QcHome", () => {
|
|||||||
expect(card).toHaveTextContent("PO 5541");
|
expect(card).toHaveTextContent("PO 5541");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters the boards on the client", async () => {
|
it("sends the find box to the server and keeps the numbers", async () => {
|
||||||
mockFetch(base());
|
const home = makeHome();
|
||||||
|
const elbow = makeHome({ columns: { ready: [], accepted: home.columns.accepted, rejected: [] } });
|
||||||
|
const fetch = mockFetch({ ...base(), "/api/qc/home": (req) => ({ body: req.url.includes("q=elbow") ? elbow : home }) });
|
||||||
renderApp("/qc");
|
renderApp("/qc");
|
||||||
await screen.findByRole("link", { name: /^L999002/ });
|
await screen.findByRole("link", { name: /^L999002/ });
|
||||||
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "elbow" } });
|
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "elbow" } });
|
||||||
expect(screen.queryByRole("link", { name: /^L999001 · WELD-PART/ })).not.toBeInTheDocument();
|
await waitFor(() => expect(screen.queryByRole("link", { name: /^L999001 · WELD-PART/ })).not.toBeInTheDocument());
|
||||||
|
expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/qc/home?q=elbow");
|
||||||
expect(screen.getByRole("link", { name: /^L999002/ })).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: /^L999002/ })).toBeInTheDocument();
|
||||||
expect(screen.getByText("Nothing here matches the filters.")).toBeInTheDocument();
|
expect(screen.getAllByText("Nothing here matches the filters.").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText(/Showing/)).toHaveTextContent("Showing 1 packet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Waiting tile filters to status qc", async () => {
|
||||||
|
const fetch = mockFetch(base());
|
||||||
|
renderApp("/qc");
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Waiting for inspection: 1" }));
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/qc/home?status=qc"));
|
||||||
|
expect(screen.getByRole("button", { name: "Waiting for inspection: 1" })).toHaveAttribute("aria-pressed", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows all folders on All, and the empty texts", async () => {
|
it("shows all folders on All, and the empty texts", async () => {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useId, useState } from "react";
|
import { useId, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { getFolders, getQcHome, type QcCard, type QcColumn, type QcFolderCard } from "../../api/qc";
|
import { getFolders, getQcHome, type QcCard, type QcColumn, type QcFolderCard } from "../../api/qc";
|
||||||
import type { ColumnDef } from "../../components/Board";
|
import { Board, type ColumnDef } from "../../components/Board";
|
||||||
import { Card } from "../../components/Card";
|
import { Card } from "../../components/Card";
|
||||||
import { FindBar } from "../../components/FindBar";
|
import { FindBar } from "../../components/FindBar";
|
||||||
|
import { JobList } from "../../components/JobList";
|
||||||
import { Kpis } from "../../components/Kpis";
|
import { Kpis } from "../../components/Kpis";
|
||||||
import type { UnitPick } from "../../components/PickUnit";
|
import { useAuth } from "../../auth/useAuth";
|
||||||
import { useChanged } from "../../lib/changes";
|
import { useChanged } from "../../lib/changes";
|
||||||
|
import { uniqueCards, useFilters, type KpiKey } from "../../lib/filters";
|
||||||
import { useLoad } from "../../lib/useLoad";
|
import { useLoad } from "../../lib/useLoad";
|
||||||
import { ProjectPill } from "../clerical/common";
|
import { ProjectPill } from "../clerical/common";
|
||||||
import { matchCard, plural } from "./folders";
|
import { plural } from "./folders";
|
||||||
|
|
||||||
const COLUMNS: ReadonlyArray<{ key: QcColumn; title: string; empty: string }> = [
|
const COLUMNS: ReadonlyArray<{ key: QcColumn; title: string; empty: string }> = [
|
||||||
{ key: "ready", title: "Ready for inspection", empty: "Supervisors send packets here when the data book package and the spool are ready." },
|
{ key: "ready", title: "Ready for inspection", empty: "Supervisors send packets here when the data book package and the spool are ready." },
|
||||||
@@ -101,25 +103,28 @@ function cardExtra(col: QcColumn, c: QcCard) {
|
|||||||
|
|
||||||
/** /qc (the old homeQC): the numbers, the three boards and the project folders. */
|
/** /qc (the old homeQC): the numbers, the three boards and the project folders. */
|
||||||
export function QcHome() {
|
export function QcHome() {
|
||||||
const [q, setQ] = useState("");
|
const { departments } = useAuth();
|
||||||
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
|
const flt = useFilters("qc");
|
||||||
const projectId = pick.project?.id;
|
const { f, query, queryKey } = flt;
|
||||||
const unitId = pick.unit?.id;
|
// The server applies every filter to the columns; the numbers follow the project and unit only.
|
||||||
const [load, reload] = useLoad(
|
const [load, reload] = useLoad((s) => getQcHome(query, s), queryKey);
|
||||||
(s) => getQcHome(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}, s),
|
|
||||||
`${projectId ?? ""}|${unitId ?? ""}`,
|
|
||||||
);
|
|
||||||
useChanged(reload, { delayMs: 500 });
|
useChanged(reload, { delayMs: 500 });
|
||||||
|
|
||||||
const data = load.state === "ok" ? load.data : null;
|
const data = load.state === "ok" ? load.data : null;
|
||||||
const k = data?.kpis;
|
const k = data?.kpis;
|
||||||
|
|
||||||
const columns = COLUMNS.map((c) => {
|
const narrowed = flt.active > 0;
|
||||||
const all = data?.columns[c.key] ?? [];
|
const columns: ColumnDef[] = useMemo(
|
||||||
const shown = all.filter((x) => matchCard(x, q));
|
() =>
|
||||||
return { ...c, cards: shown, empty: all.length && !shown.length ? "Nothing here matches the filters." : c.empty };
|
COLUMNS.map((c) => {
|
||||||
});
|
const list = data?.columns[c.key] ?? [];
|
||||||
|
return { key: c.key, title: c.title, cards: list, empty: narrowed && !list.length ? "Nothing here matches the filters." : c.empty };
|
||||||
|
}),
|
||||||
|
[data, narrowed],
|
||||||
|
);
|
||||||
const count = data ? columns.reduce((n, c) => n + c.cards.length, 0) : null;
|
const count = data ? columns.reduce((n, c) => n + c.cards.length, 0) : null;
|
||||||
|
const rows = useMemo(() => uniqueCards(columns.map((c) => c.cards)), [columns]);
|
||||||
|
const kpi = (key: KpiKey, value: string) => ({ filter: { key, value }, on: flt.isKpiOn(key, value) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -132,40 +137,39 @@ export function QcHome() {
|
|||||||
|
|
||||||
<Kpis
|
<Kpis
|
||||||
items={[
|
items={[
|
||||||
{ n: k?.waiting ?? 0, label: "Waiting for inspection", alert: true },
|
{ n: k?.waiting ?? 0, label: "Waiting for inspection", alert: true, ...kpi("status", "qc") },
|
||||||
{ n: k?.accepted_7d ?? 0, label: "Accepted last 7 days" },
|
{ n: k?.accepted_7d ?? 0, label: "Accepted last 7 days" },
|
||||||
{ n: k?.rejected_7d ?? 0, label: "Rejected last 7 days", alert: true },
|
{ n: k?.rejected_7d ?? 0, label: "Rejected last 7 days", alert: true },
|
||||||
]}
|
]}
|
||||||
|
onFilter={(x) => flt.tapKpi(x.key, x.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
|
<FindBar
|
||||||
|
f={f}
|
||||||
|
onChange={flt.set}
|
||||||
|
onClear={flt.clear}
|
||||||
|
pick={flt.pick}
|
||||||
|
onPick={flt.setPick}
|
||||||
|
view={flt.view}
|
||||||
|
onView={flt.setView}
|
||||||
|
count={count}
|
||||||
|
facets={data?.facets ?? null}
|
||||||
|
depts={departments}
|
||||||
|
screen="qc"
|
||||||
|
/>
|
||||||
|
|
||||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||||
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
{load.state === "error" ? <div className="callout warn">{load.message}</div> : null}
|
||||||
|
|
||||||
{data ? (
|
{data ? (
|
||||||
<div className="board">
|
flt.view === "board" ? (
|
||||||
{columns.map((c) => (
|
<Board columns={columns} render={(p, col) => <Card p={p} extra={cardExtra(col as QcColumn, p as QcCard)} />} />
|
||||||
<QcColumnView key={c.key} title={c.title} col={c.key} cards={c.cards} empty={c.empty} />
|
) : (
|
||||||
))}
|
<JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} />
|
||||||
</div>
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{data ? <FoldersSection active={data.folders} /> : null}
|
{data ? <FoldersSection active={data.folders} /> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The shared Column, with the Q.C. lines under each card. */
|
|
||||||
function QcColumnView({ title, col, cards, empty }: { title: string; col: QcColumn; cards: QcCard[]; empty: ColumnDef["empty"] }) {
|
|
||||||
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
||||||
return (
|
|
||||||
<section className="col" aria-labelledby={id}>
|
|
||||||
<h3 className="col-h" id={id}>
|
|
||||||
{title}
|
|
||||||
<span className="c">{cards.length}</span>
|
|
||||||
</h3>
|
|
||||||
{cards.length ? cards.map((p) => <Card key={p.id} p={p} extra={cardExtra(col, p)} />) : <div className="empty">{empty}</div>}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
getStall,
|
getStall,
|
||||||
getStations,
|
getStations,
|
||||||
@@ -6,8 +6,14 @@ import {
|
|||||||
type StallAnswer,
|
type StallAnswer,
|
||||||
type StationOption,
|
type StationOption,
|
||||||
} from "../../api/shop";
|
} from "../../api/shop";
|
||||||
|
import { CardSlot } from "../../components/Board";
|
||||||
import { Card } from "../../components/Card";
|
import { Card } from "../../components/Card";
|
||||||
|
import { FindBar } from "../../components/FindBar";
|
||||||
|
import { JobList } from "../../components/JobList";
|
||||||
|
import { useAuth } from "../../auth/useAuth";
|
||||||
import { useChanged } from "../../lib/changes";
|
import { useChanged } from "../../lib/changes";
|
||||||
|
import { uniqueCards, useFilters } from "../../lib/filters";
|
||||||
|
import { sigMap, useFlip } from "../../lib/useFlip";
|
||||||
import { useLoad } from "../../lib/useLoad";
|
import { useLoad } from "../../lib/useLoad";
|
||||||
import {
|
import {
|
||||||
asCard,
|
asCard,
|
||||||
@@ -80,7 +86,9 @@ function StallColumn({
|
|||||||
</h3>
|
</h3>
|
||||||
{cards.length ? (
|
{cards.length ? (
|
||||||
cards.map((p) => (
|
cards.map((p) => (
|
||||||
<StallCard key={p.id} p={p} fetchedAt={fetchedAt} now={now} />
|
<CardSlot key={p.id} id={p.id}>
|
||||||
|
<StallCard p={p} fetchedAt={fetchedAt} now={now} />
|
||||||
|
</CardSlot>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="empty">{empty}</div>
|
<div className="empty">{empty}</div>
|
||||||
@@ -110,18 +118,26 @@ export function StallHome() {
|
|||||||
lsSet(STALL_KEY, v);
|
lsSet(STALL_KEY, v);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { departments } = useAuth();
|
||||||
|
const flt = useFilters("stall", { stationId: station?.id ?? null });
|
||||||
|
const { f, query, queryKey } = flt;
|
||||||
const [stall, reload] = useLoad<LoadedStall | null>(
|
const [stall, reload] = useLoad<LoadedStall | null>(
|
||||||
async (s) =>
|
async (s) =>
|
||||||
station
|
station
|
||||||
? { ...(await getStall(station.id, s)), fetchedAt: Date.now() }
|
? { ...(await getStall(station.id, s, query)), fetchedAt: Date.now() }
|
||||||
: null,
|
: null,
|
||||||
String(station?.id ?? ""),
|
`${station?.id ?? ""}|${queryKey}`,
|
||||||
);
|
);
|
||||||
useChanged(reload, { delayMs: 500 });
|
useChanged(reload, { delayMs: 500 });
|
||||||
|
|
||||||
const data = stall.state === "ok" ? stall.data : null;
|
const data = stall.state === "ok" ? stall.data : null;
|
||||||
const running = data ? data.mine.some((p) => p.clock?.running) : false;
|
const running = data ? data.mine.some((p) => p.clock?.running) : false;
|
||||||
const now = useNow(running);
|
const now = useNow(running);
|
||||||
|
const boardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const cols = useMemo(() => (data ? [data.mine, data.submitted] : []), [data]);
|
||||||
|
const sigs = useMemo(() => sigMap(cols.flat()), [cols]);
|
||||||
|
useFlip(boardRef, "[data-card-id]", [cols], sigs);
|
||||||
|
const rows = useMemo(() => uniqueCards(cols).map(asCard), [cols]);
|
||||||
|
|
||||||
if (stations.state === "loading")
|
if (stations.state === "loading")
|
||||||
return <div className="empty slim">Loading</div>;
|
return <div className="empty slim">Loading</div>;
|
||||||
@@ -166,8 +182,23 @@ export function StallHome() {
|
|||||||
<div className="callout warn">{stall.message}</div>
|
<div className="callout warn">{stall.message}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{data ? (
|
<FindBar
|
||||||
<div className="board">
|
f={f}
|
||||||
|
onChange={flt.set}
|
||||||
|
onClear={flt.clear}
|
||||||
|
pick={flt.pick}
|
||||||
|
onPick={flt.setPick}
|
||||||
|
view={flt.view}
|
||||||
|
onView={flt.setView}
|
||||||
|
count={data ? data.mine.length + data.submitted.length : null}
|
||||||
|
facets={null}
|
||||||
|
depts={departments}
|
||||||
|
screen="stall"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{data && flt.view === "list" ? <JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} /> : null}
|
||||||
|
{data && flt.view === "board" ? (
|
||||||
|
<div className="board" ref={boardRef}>
|
||||||
<StallColumn
|
<StallColumn
|
||||||
title="My packets"
|
title="My packets"
|
||||||
cards={data.mine}
|
cards={data.mine}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function StationJob({ p, fetchedAt, now }: { p: ShopCard; fetchedAt: number; now
|
|||||||
const hrs = ms / 3_600_000;
|
const hrs = ms / 3_600_000;
|
||||||
const stamp = p.welder_stamp ?? p.welder?.stamp ?? null;
|
const stamp = p.welder_stamp ?? p.welder?.stamp ?? null;
|
||||||
return (
|
return (
|
||||||
<div className="stjob">
|
<div className="stjob" data-card-id={p.id}>
|
||||||
<button type="button" className="stopen" onClick={() => navigate(`/packets/${p.id}`)}>
|
<button type="button" className="stopen" onClick={() => navigate(`/packets/${p.id}`)}>
|
||||||
{jobName(p)}
|
{jobName(p)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -186,14 +186,46 @@ describe("SupervisorHome", () => {
|
|||||||
expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/board?dept=all")).toBe(false);
|
expect(fetch.mock.calls.some(([u]) => String(u) === "/api/shop/board?dept=all")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides the chips with one department and filters the boards with the find box", async () => {
|
it("hides the chips with one department and sends the find box to the server", async () => {
|
||||||
mockFetch(base());
|
const elbow = board({ columns: { ready: [shopCard(3, { status: "ready", title: "Elbow" })], review: [], qc: [], warehouse: [], complete: [] } });
|
||||||
|
const fetch = mockFetch({ ...base(), "/api/shop/board": (req) => ({ body: req.url.includes("q=elbow") ? elbow : board() }) });
|
||||||
renderApp("/supervisor");
|
renderApp("/supervisor");
|
||||||
await screen.findByRole("link", { name: /^L662003/ });
|
await screen.findByRole("link", { name: /^L662003/ });
|
||||||
expect(screen.queryByRole("group", { name: "Department" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("group", { name: "Department" })).not.toBeInTheDocument();
|
||||||
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "elbow" } });
|
fireEvent.change(screen.getByLabelText("Find a job"), { target: { value: "elbow" } });
|
||||||
expect(screen.queryByRole("link", { name: /^L662002/ })).not.toBeInTheDocument();
|
await waitFor(() => expect(screen.queryByRole("link", { name: /^L662002/ })).not.toBeInTheDocument());
|
||||||
|
expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/shop/board?dept=all&q=elbow");
|
||||||
expect(screen.getByRole("link", { name: /^L662003/ })).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: /^L662003/ })).toBeInTheDocument();
|
||||||
|
// The search text is never saved.
|
||||||
|
expect(window.localStorage.getItem("ps.flt") ?? "").not.toContain("elbow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a KPI tap filters the boards by status, the numbers stay, and a second tap clears it", async () => {
|
||||||
|
const fetch = mockFetch(base());
|
||||||
|
renderApp("/supervisor");
|
||||||
|
const tile = await screen.findByRole("button", { name: "Ready to assign: 2" });
|
||||||
|
expect(tile).toHaveAttribute("aria-pressed", "false");
|
||||||
|
fireEvent.click(tile);
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/shop/board?dept=all&status=ready"));
|
||||||
|
expect(screen.getByRole("button", { name: "Ready to assign: 2" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.getByRole("button", { name: "Ready to assign: 2" })).toHaveClass("on");
|
||||||
|
// The advanced row opened with the status set.
|
||||||
|
expect(screen.getByLabelText("Status")).toHaveValue("ready");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Ready to assign: 2" }));
|
||||||
|
await waitFor(() => expect(screen.getByRole("button", { name: "Ready to assign: 2" })).toHaveAttribute("aria-pressed", "false"));
|
||||||
|
expect(screen.getByLabelText("Status")).toHaveValue("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the list view lists the board and station packets once each, sorted on the server", async () => {
|
||||||
|
const fetch = mockFetch(base());
|
||||||
|
renderApp("/supervisor");
|
||||||
|
await screen.findByRole("link", { name: /^L662003/ });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "List" }));
|
||||||
|
const table = await screen.findByRole("table", { name: "Jobs" });
|
||||||
|
expect(within(table).getAllByRole("row")).toHaveLength(1 + 3);
|
||||||
|
fireEvent.change(screen.getByLabelText("Sort by"), { target: { value: "project" } });
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.map(([u]) => String(u))).toContain("/api/shop/board?dept=all&sort=project"));
|
||||||
|
expect(JSON.parse(window.localStorage.getItem("ps.flt") ?? "{}")).toMatchObject({ sort: "project", views: { supervisor: "list" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the server's refusal", async () => {
|
it("shows the server's refusal", async () => {
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ import { useAuth } from "../../auth/useAuth";
|
|||||||
import { Board, type ColumnDef } from "../../components/Board";
|
import { Board, type ColumnDef } from "../../components/Board";
|
||||||
import { CoBanner, type CoBannerItem } from "../../components/CoBanner";
|
import { CoBanner, type CoBannerItem } from "../../components/CoBanner";
|
||||||
import { FindBar } from "../../components/FindBar";
|
import { FindBar } from "../../components/FindBar";
|
||||||
|
import { JobList } from "../../components/JobList";
|
||||||
import { Kpis } from "../../components/Kpis";
|
import { Kpis } from "../../components/Kpis";
|
||||||
import type { UnitPick } from "../../components/PickUnit";
|
|
||||||
import { useChanged } from "../../lib/changes";
|
import { useChanged } from "../../lib/changes";
|
||||||
|
import { uniqueCards, useFilters, type KpiKey } from "../../lib/filters";
|
||||||
import { fmtTime } from "../../lib/format";
|
import { fmtTime } from "../../lib/format";
|
||||||
import { useShopDepartments } from "../../lib/useDepartments";
|
import { useShopDepartments } from "../../lib/useDepartments";
|
||||||
import { useLoad } from "../../lib/useLoad";
|
import { useLoad } from "../../lib/useLoad";
|
||||||
import { CoTable } from "./CoTable";
|
import { CoTable } from "./CoTable";
|
||||||
import { asCard, coHref, deptShort, fmtHrs, lsGet, lsSet, matches, useNow } from "./shopView";
|
import { asCard, coHref, deptShort, fmtHrs, lsGet, lsSet, useNow } from "./shopView";
|
||||||
import { StationGrid } from "./StationGrid";
|
import { StationGrid } from "./StationGrid";
|
||||||
|
|
||||||
/** localStorage key for the department scope (the old ps.supdept). */
|
/** localStorage key for the department scope (the old ps.supdept). */
|
||||||
@@ -30,7 +31,7 @@ type Loaded = ShopBoard & { fetchedAt: number };
|
|||||||
|
|
||||||
/** /supervisor (the old homeSupervisor): the change order headline, numbers, stations and the five boards. */
|
/** /supervisor (the old homeSupervisor): the change order headline, numbers, stations and the five boards. */
|
||||||
export function SupervisorHome() {
|
export function SupervisorHome() {
|
||||||
const { me } = useAuth();
|
const { me, departments } = useAuth();
|
||||||
const roles = useMemo(() => me?.roles ?? [], [me]);
|
const roles = useMemo(() => me?.roles ?? [], [me]);
|
||||||
const shop = useShopDepartments();
|
const shop = useShopDepartments();
|
||||||
const isAdmin = roles.includes("admin");
|
const isAdmin = roles.includes("admin");
|
||||||
@@ -43,17 +44,16 @@ export function SupervisorHome() {
|
|||||||
lsSet(SUP_DEPT_KEY, v);
|
lsSet(SUP_DEPT_KEY, v);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [q, setQ] = useState("");
|
const flt = useFilters("supervisor");
|
||||||
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
|
const { f, query, queryKey } = flt;
|
||||||
const projectId = pick.project?.id;
|
|
||||||
const unitId = pick.unit?.id;
|
|
||||||
|
|
||||||
|
// The department chips scope the board; the find bar's Department, when set, narrows it further.
|
||||||
const [load, reload] = useLoad<Loaded>(
|
const [load, reload] = useLoad<Loaded>(
|
||||||
async (s) => {
|
async (s) => {
|
||||||
const b = await getBoard({ dept, ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s);
|
const b = await getBoard({ dept, ...query }, s);
|
||||||
return { ...b, fetchedAt: Date.now() };
|
return { ...b, fetchedAt: Date.now() };
|
||||||
},
|
},
|
||||||
`${dept}|${projectId ?? ""}|${unitId ?? ""}`,
|
`${dept}|${queryKey}`,
|
||||||
);
|
);
|
||||||
useChanged(reload, { delayMs: 500 });
|
useChanged(reload, { delayMs: 500 });
|
||||||
|
|
||||||
@@ -93,18 +93,20 @@ export function SupervisorHome() {
|
|||||||
const k = data?.kpis;
|
const k = data?.kpis;
|
||||||
const elapsedMs = (k?.elapsed_ms ?? 0) + (data ? running * Math.max(0, now - data.fetchedAt) : 0);
|
const elapsedMs = (k?.elapsed_ms ?? 0) + (data ? running * Math.max(0, now - data.fetchedAt) : 0);
|
||||||
|
|
||||||
const filtered = (list: ShopCard[]) => list.filter((c) => matches(c, q));
|
const narrowed = flt.active > 0;
|
||||||
const columns: ColumnDef[] = COLUMNS.map((c) => {
|
const columns: ColumnDef[] = useMemo(
|
||||||
const all = data?.columns[c.key] ?? [];
|
() =>
|
||||||
const shown = filtered(all);
|
COLUMNS.map((c) => {
|
||||||
return {
|
const list: ShopCard[] = data?.columns[c.key] ?? [];
|
||||||
key: c.key,
|
return { key: c.key, title: c.title, cards: list.map(asCard), empty: narrowed && !list.length ? "Nothing here matches the filters." : c.empty };
|
||||||
title: c.title,
|
}),
|
||||||
cards: shown.map(asCard),
|
[data, narrowed],
|
||||||
empty: all.length && !shown.length ? "Nothing here matches the filters." : c.empty,
|
);
|
||||||
};
|
// The list holds what this screen shows: the board columns and the packets at the stations.
|
||||||
});
|
const rows = useMemo(() => uniqueCards([...columns.map((c) => c.cards), atStations.map(asCard)]), [columns, atStations]);
|
||||||
const count = data ? columns.reduce((n, c) => n + c.cards.length, 0) : null;
|
// The count follows the old build: every packet this screen shows, the station grid included.
|
||||||
|
const count = data ? rows.length : null;
|
||||||
|
const kpi = (key: KpiKey, value: string) => ({ filter: { key, value }, on: flt.isKpiOn(key, value) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -133,11 +135,12 @@ export function SupervisorHome() {
|
|||||||
|
|
||||||
<Kpis
|
<Kpis
|
||||||
items={[
|
items={[
|
||||||
{ n: k?.ready ?? 0, label: "Ready to assign", alert: true },
|
{ n: k?.ready ?? 0, label: "Ready to assign", alert: true, ...kpi("status", "ready") },
|
||||||
{ n: k?.at_stations ?? 0, label: "At stations" },
|
{ n: k?.at_stations ?? 0, label: "At stations", ...kpi("status", "station") },
|
||||||
{ n: k?.review ?? 0, label: "Waiting on review", alert: true },
|
{ n: k?.review ?? 0, label: "Waiting on review", alert: true, ...kpi("status", "review") },
|
||||||
{ n: `${fmtHrs(elapsedMs / 3_600_000)} / ${fmtHrs(Number(k?.target_hrs) || 0)} h`, label: "Station hours vs target" },
|
{ n: `${fmtHrs(elapsedMs / 3_600_000)} / ${fmtHrs(Number(k?.target_hrs) || 0)} h`, label: "Station hours vs target" },
|
||||||
]}
|
]}
|
||||||
|
onFilter={(x) => flt.tapKpi(x.key, x.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||||
@@ -146,9 +149,27 @@ export function SupervisorHome() {
|
|||||||
{data ? <CoTable rows={onHold} names={names} canVerify={canVerify} /> : null}
|
{data ? <CoTable rows={onHold} names={names} canVerify={canVerify} /> : null}
|
||||||
{data ? <StationGrid depts={data.depts} names={names} fetchedAt={data.fetchedAt} now={now} /> : null}
|
{data ? <StationGrid depts={data.depts} names={names} fetchedAt={data.fetchedAt} now={now} /> : null}
|
||||||
|
|
||||||
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
|
<FindBar
|
||||||
|
f={f}
|
||||||
|
onChange={flt.set}
|
||||||
|
onClear={flt.clear}
|
||||||
|
pick={flt.pick}
|
||||||
|
onPick={flt.setPick}
|
||||||
|
view={flt.view}
|
||||||
|
onView={flt.setView}
|
||||||
|
count={count}
|
||||||
|
facets={data?.facets ?? null}
|
||||||
|
depts={departments.filter((d) => mine.includes(d.code))}
|
||||||
|
screen="supervisor"
|
||||||
|
/>
|
||||||
|
|
||||||
{data ? <Board columns={columns} /> : null}
|
{data ? (
|
||||||
|
flt.view === "board" ? (
|
||||||
|
<Board columns={columns} />
|
||||||
|
) : (
|
||||||
|
<JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
import { packetsApi } from "../../api/packets";
|
import { packetsApi } from "../../api/packets";
|
||||||
import type { BoardColumn, PacketCard, ReceiptBody, ShortRow, WhStage } from "../../api/types";
|
import type { BoardColumn, PacketCard, ReceiptBody, ShortRow, WhStage } from "../../api/types";
|
||||||
import { warehouseApi } from "../../api/warehouse";
|
import { warehouseApi } from "../../api/warehouse";
|
||||||
import { useAuth } from "../../auth/useAuth";
|
import { useAuth } from "../../auth/useAuth";
|
||||||
import { FindBar } from "../../components/FindBar";
|
import { FindBar } from "../../components/FindBar";
|
||||||
|
import { JobList } from "../../components/JobList";
|
||||||
import { Kpis } from "../../components/Kpis";
|
import { Kpis } from "../../components/Kpis";
|
||||||
import type { UnitPick } from "../../components/PickUnit";
|
|
||||||
import { useGuard } from "../../components/useGuard";
|
import { useGuard } from "../../components/useGuard";
|
||||||
import { useChanged } from "../../lib/changes";
|
import { useChanged } from "../../lib/changes";
|
||||||
|
import { uniqueCards, useFilters, type KpiKey } from "../../lib/filters";
|
||||||
import { errorText } from "../../lib/format";
|
import { errorText } from "../../lib/format";
|
||||||
import { jobName } from "../../lib/packets";
|
import { jobName } from "../../lib/packets";
|
||||||
import { useLoad } from "../../lib/useLoad";
|
import { useLoad } from "../../lib/useLoad";
|
||||||
@@ -20,20 +21,12 @@ import { RELEASE_REFUSED, WhBoard } from "./WhBoard";
|
|||||||
|
|
||||||
/** /warehouse (the old homeWarehouse): numbers, the open shorts, the find bar and the five-column board. */
|
/** /warehouse (the old homeWarehouse): numbers, the open shorts, the find bar and the five-column board. */
|
||||||
export function WarehouseHome() {
|
export function WarehouseHome() {
|
||||||
const { me } = useAuth();
|
const { me, departments } = useAuth();
|
||||||
const { run, sheet: guardSheet } = useGuard();
|
const { run, sheet: guardSheet } = useGuard();
|
||||||
const [q, setQ] = useState("");
|
const flt = useFilters("warehouse");
|
||||||
const [query, setQuery] = useState("");
|
const { f, query, queryKey } = flt;
|
||||||
const [pick, setPick] = useState<UnitPick>({ project: null, unit: null });
|
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
const [receiving, setReceiving] = useState<ShortRow | null>(null);
|
const [receiving, setReceiving] = useState<ShortRow | null>(null);
|
||||||
const projectId = pick.project?.id;
|
|
||||||
const unitId = pick.unit?.id;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = window.setTimeout(() => setQuery(q.trim()), 250);
|
|
||||||
return () => window.clearTimeout(t);
|
|
||||||
}, [q]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!toast) return;
|
if (!toast) return;
|
||||||
@@ -41,10 +34,8 @@ export function WarehouseHome() {
|
|||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
const [load, reload] = useLoad(
|
// The server applies every filter to the columns; the numbers follow the project and unit only.
|
||||||
(s) => warehouseApi.board({ ...(query ? { q: query } : {}), ...(unitId ? { unit_id: unitId } : projectId ? { project_id: projectId } : {}) }, s),
|
const [load, reload] = useLoad((s) => warehouseApi.board(query, s), queryKey);
|
||||||
`${query}|${projectId ?? ""}|${unitId ?? ""}`,
|
|
||||||
);
|
|
||||||
useChanged(reload, { delayMs: 500 });
|
useChanged(reload, { delayMs: 500 });
|
||||||
|
|
||||||
const onError = useCallback(
|
const onError = useCallback(
|
||||||
@@ -81,6 +72,8 @@ export function WarehouseHome() {
|
|||||||
const k = data?.kpis;
|
const k = data?.kpis;
|
||||||
const canMove = isWarehouseUser(me?.roles ?? []);
|
const canMove = isWarehouseUser(me?.roles ?? []);
|
||||||
const count = data ? (["incoming", "pulling", "hold", "pulled"] as const).reduce((n, c) => n + data.columns[c].length, 0) : null;
|
const count = data ? (["incoming", "pulling", "hold", "pulled"] as const).reduce((n, c) => n + data.columns[c].length, 0) : null;
|
||||||
|
const rows = useMemo(() => (data ? uniqueCards(Object.values(data.columns)) : []), [data]);
|
||||||
|
const kpi = (key: KpiKey, value: string) => ({ filter: { key, value }, on: flt.isKpiOn(key, value) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -93,13 +86,14 @@ export function WarehouseHome() {
|
|||||||
|
|
||||||
<Kpis
|
<Kpis
|
||||||
items={[
|
items={[
|
||||||
{ n: k?.waiting ?? 0, label: "Waiting to pull" },
|
{ n: k?.waiting ?? 0, label: "Waiting to pull", ...kpi("status", "warehouse") },
|
||||||
{ n: k?.pulling ?? 0, label: "Being pulled" },
|
{ n: k?.pulling ?? 0, label: "Being pulled" },
|
||||||
{ n: k?.hold ?? 0, label: "On hold", alert: true },
|
{ n: k?.hold ?? 0, label: "On hold", alert: true },
|
||||||
{ n: k?.pulled ?? 0, label: "Ready for production", alert: true },
|
{ n: k?.pulled ?? 0, label: "Ready for production", alert: true },
|
||||||
{ n: k?.open_shorts ?? 0, label: "Open shorts", alert: true },
|
{ n: k?.open_shorts ?? 0, label: "Open shorts", alert: true, ...kpi("flag", "short") },
|
||||||
{ n: k?.released_24h ?? 0, label: "Released last 24 h" },
|
{ n: k?.released_24h ?? 0, label: "Released last 24 h" },
|
||||||
]}
|
]}
|
||||||
|
onFilter={(x) => flt.tapKpi(x.key, x.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
{load.state === "loading" ? <div className="empty slim">Loading</div> : null}
|
||||||
@@ -107,9 +101,27 @@ export function WarehouseHome() {
|
|||||||
|
|
||||||
{data ? <ShortsTable rows={data.shorts} busy={wh.busy} onReceive={setReceiving} /> : null}
|
{data ? <ShortsTable rows={data.shorts} busy={wh.busy} onReceive={setReceiving} /> : null}
|
||||||
|
|
||||||
<FindBar q={q} onQ={setQ} pick={pick} onPick={setPick} count={count} />
|
<FindBar
|
||||||
|
f={f}
|
||||||
|
onChange={flt.set}
|
||||||
|
onClear={flt.clear}
|
||||||
|
pick={flt.pick}
|
||||||
|
onPick={flt.setPick}
|
||||||
|
view={flt.view}
|
||||||
|
onView={flt.setView}
|
||||||
|
count={count}
|
||||||
|
facets={data?.facets ?? null}
|
||||||
|
depts={departments}
|
||||||
|
screen="warehouse"
|
||||||
|
/>
|
||||||
|
|
||||||
{data ? <WhBoard columns={data.columns} canMove={canMove} busy={wh.busy} onMove={onMove} onDrop={onDrop} /> : null}
|
{data ? (
|
||||||
|
flt.view === "board" ? (
|
||||||
|
<WhBoard columns={data.columns} canMove={canMove} busy={wh.busy} onMove={onMove} onDrop={onDrop} />
|
||||||
|
) : (
|
||||||
|
<JobList rows={rows} sort={f.sort} onSort={(sort) => flt.set({ sort })} />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
{receiving ? (
|
{receiving ? (
|
||||||
<ShortSheet
|
<ShortSheet
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useRef, type KeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
|
import { useMemo, useRef, type KeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import type { BoardColumn, BoardResponse, PacketCard, WhStage } from "../../api/types";
|
import type { BoardColumn, BoardResponse, PacketCard, WhStage } from "../../api/types";
|
||||||
|
import { CardSlot } from "../../components/Board";
|
||||||
import { Card } from "../../components/Card";
|
import { Card } from "../../components/Card";
|
||||||
import { fmtDay } from "../../lib/format";
|
import { fmtDay } from "../../lib/format";
|
||||||
|
import { sigMap, useFlip } from "../../lib/useFlip";
|
||||||
import { jobName } from "../../lib/packets";
|
import { jobName } from "../../lib/packets";
|
||||||
import { cardProgress, daysSince, pillClassFor, pillText, WH_STAGES, whLine, whStage } from "../../warehouse/stages";
|
import { cardProgress, daysSince, pillClassFor, pillText, WH_STAGES, whLine, whStage } from "../../warehouse/stages";
|
||||||
import { groupByDept, WH_COLUMNS } from "./boards";
|
import { groupByDept, WH_COLUMNS } from "./boards";
|
||||||
@@ -104,6 +106,7 @@ export function WhCard({ p, canMove, busy, onMove, onDrop }: CardProps) {
|
|||||||
<div
|
<div
|
||||||
className={canMove ? "card whcard whdrag" : "card whcard"}
|
className={canMove ? "card whcard whdrag" : "card whcard"}
|
||||||
data-whpid={p.id}
|
data-whpid={p.id}
|
||||||
|
data-card-id={p.id}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={`${jobName(p)}, ${WH_STAGES[s].name}`}
|
aria-label={`${jobName(p)}, ${WH_STAGES[s].name}`}
|
||||||
@@ -189,8 +192,11 @@ export function WhBoard({
|
|||||||
onMove: (p: PacketCard, to: WhStage) => void;
|
onMove: (p: PacketCard, to: WhStage) => void;
|
||||||
onDrop: (p: PacketCard, to: BoardColumn) => void;
|
onDrop: (p: PacketCard, to: BoardColumn) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const sigs = useMemo(() => sigMap(Object.values(columns).flat()), [columns]);
|
||||||
|
useFlip(ref, "[data-card-id]", [columns], sigs);
|
||||||
return (
|
return (
|
||||||
<div className="board whboard">
|
<div className="board whboard" ref={ref}>
|
||||||
{WH_COLUMNS.map((c) => {
|
{WH_COLUMNS.map((c) => {
|
||||||
const list = columns[c.key];
|
const list = columns[c.key];
|
||||||
const id = `whcol-${c.key}`;
|
const id = `whcol-${c.key}`;
|
||||||
@@ -205,7 +211,11 @@ export function WhBoard({
|
|||||||
{!list.length ? (
|
{!list.length ? (
|
||||||
<div className="empty">{c.empty}</div>
|
<div className="empty">{c.empty}</div>
|
||||||
) : c.key === "released" ? (
|
) : c.key === "released" ? (
|
||||||
list.map((p) => <Card key={p.id} p={p} />)
|
list.map((p) => (
|
||||||
|
<CardSlot key={p.id} id={p.id}>
|
||||||
|
<Card p={p} />
|
||||||
|
</CardSlot>
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
groupByDept(list).map((grp) => (
|
groupByDept(list).map((grp) => (
|
||||||
<div key={grp.dept.code} className="whgrpbox">
|
<div key={grp.dept.code} className="whgrpbox">
|
||||||
|
|||||||
@@ -639,3 +639,119 @@ a.itpcard { flex-direction: row; text-align: left; }
|
|||||||
.ck-item.prow .t .small { display: block; margin-top: 2px; font-weight: 400; }
|
.ck-item.prow .t .small { display: block; margin-top: 2px; font-weight: 400; }
|
||||||
.prints { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
.prints { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||||
.callout .acts .grow { min-width: 240px; }
|
.callout .acts .grow { min-width: 240px; }
|
||||||
|
|
||||||
|
/* Phase 8: Project Management. From .pmtabs, .pmscope, .pmk, .pmsum, .pmbars, .pmbar, .slowtag, .pmtable. */
|
||||||
|
.pmtabs { position: static; margin: 14px 0 10px; }
|
||||||
|
.pmtabs button { font-size: 15px; }
|
||||||
|
.pmscope { margin: -6px 0 14px; }
|
||||||
|
.pmk { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
||||||
|
.pmk .kpi .n { font-size: 38px; }
|
||||||
|
.pmsum h3 { margin-bottom: 6px; }
|
||||||
|
.pmsum ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; color: var(--near); }
|
||||||
|
.pmbars { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.pmbar { display: grid; grid-template-columns: minmax(150px, 240px) minmax(0, 1fr) minmax(130px, 200px); gap: 12px; align-items: center; font-size: 13.5px; }
|
||||||
|
.pmbar .lb { color: var(--body); }
|
||||||
|
.pmbar .tr { height: 14px; background: var(--line); border-radius: 3px; overflow: hidden; }
|
||||||
|
.pmbar .tr i { display: block; height: 100%; background: var(--cyan); border-radius: 0 4px 4px 0; min-width: 0; }
|
||||||
|
.pmbar.hot .tr i { background: var(--pink); }
|
||||||
|
.pmbar.zero { opacity: .5; }
|
||||||
|
.pmbar .v { color: var(--near); font-variant-numeric: tabular-nums; }
|
||||||
|
.slowtag { color: var(--pink); font-weight: 700; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.pmtable td { font-size: 13.5px; }
|
||||||
|
.pmtable tr.flag td { color: #ffb3d6; }
|
||||||
|
.pmtable tr.sub td { background: rgba(255, 255, 255, .02); font-size: 13px; }
|
||||||
|
.pmtable tr.rowlink:hover td { background: rgba(73, 192, 222, .08); }
|
||||||
|
.motion .m-enter .pmbar .tr i { transform-origin: left center; animation: mgrow .6s .15s cubic-bezier(.2, .7, .2, 1) both; }
|
||||||
|
/* New: the period select's width, the note under a table heading, sideways scroll for wide report tables. */
|
||||||
|
.pmper { min-width: 170px; }
|
||||||
|
.pmnote { margin: -4px 0 8px; }
|
||||||
|
.pmtable { min-width: 720px; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pmbar { grid-template-columns: 1fr; gap: 4px; }
|
||||||
|
.pmbar .v { font-size: 12.5px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phase 8: find bar, motion, layout. From the old .fltbar, .fltrow, .fltadv, .fltbtns, .fltn, .fltrow2 .chip,
|
||||||
|
.projsum, .joblist rows, .mbtn, .mdot, .m-enter, .m-tab, .m-bump and the motion keyframes. Then the sheet
|
||||||
|
placement and the iPad and phone widths, which are new in the rebuild. */
|
||||||
|
.fltrow .f { flex: 1 1 160px; min-width: 130px; }
|
||||||
|
.fltrow .f select, .fltrow .f input { width: 100%; }
|
||||||
|
.fltrow .fltq { flex: 3 1 320px; }
|
||||||
|
.fltrow .fltp { flex: 1.4 1 260px; }
|
||||||
|
.fltbtns { flex-wrap: wrap; }
|
||||||
|
.fltbtns .btn { min-height: 44px; }
|
||||||
|
.fltbtns .btn.on { border-color: var(--cyan); color: var(--cyan); }
|
||||||
|
.fltn { display: inline-grid; place-items: center; min-width: 20px; height: 20px; border-radius: 10px; background: var(--cyan); color: #06222b; font-size: 12px; padding: 0 5px; }
|
||||||
|
.fltadv { padding-top: 2px; }
|
||||||
|
.fltrow2 .chip { min-height: 36px; padding: 5px 12px; font-size: 13px; }
|
||||||
|
.fltbar .projsum { padding: 8px 10px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.joblist-sec .sec-h { flex-wrap: wrap; align-items: flex-end; }
|
||||||
|
.jlsort { display: flex; flex-direction: column; gap: 4px; min-width: 200px; }
|
||||||
|
.jlsort > span { font-size: 11.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
|
||||||
|
.joblist tr.rowlink:focus td { background: rgba(73, 192, 222, .08); }
|
||||||
|
.cardw { border-radius: 10px; }
|
||||||
|
.cardw > .card { height: 100%; }
|
||||||
|
.presence { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* The Motion switch in the top bar. */
|
||||||
|
.mbtn { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.mbtn .mdot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||||
|
|
||||||
|
/* Motion, only under body.motion. Reduced motion never gets the class, and the rule at the top stops it anyway. */
|
||||||
|
@keyframes menter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes mtab { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes mgrow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||||
|
@keyframes mglow { 0% { box-shadow: 0 0 0 0 rgba(73, 192, 222, 0); } 15% { box-shadow: 0 0 0 3px rgba(73, 192, 222, .75), 0 0 22px rgba(73, 192, 222, .45); } 100% { box-shadow: 0 0 0 0 rgba(73, 192, 222, 0); } }
|
||||||
|
@keyframes mlive { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.7); } }
|
||||||
|
@keyframes mbump { 30% { color: var(--cyan); } }
|
||||||
|
.motion .m-enter > * { animation: menter .32s cubic-bezier(.2, .7, .2, 1) both; }
|
||||||
|
.motion .m-enter > *:nth-child(2) { animation-delay: .04s; }
|
||||||
|
.motion .m-enter > *:nth-child(3) { animation-delay: .08s; }
|
||||||
|
.motion .m-enter > *:nth-child(4) { animation-delay: .12s; }
|
||||||
|
.motion .m-enter > *:nth-child(5) { animation-delay: .16s; }
|
||||||
|
.motion .m-enter > *:nth-child(n+6) { animation-delay: .2s; }
|
||||||
|
.motion .m-enter .bar i, .motion .m-enter .pbar i { transform-origin: left center; animation: mgrow .6s .15s cubic-bezier(.2, .7, .2, 1) both; }
|
||||||
|
.motion .m-tab { animation: mtab .2s cubic-bezier(.2, .7, .2, 1) both; }
|
||||||
|
.motion .m-changed { animation: mglow 1.7s ease-out; }
|
||||||
|
.motion .m-bump .n { animation: mbump .5s ease-out; }
|
||||||
|
.motion .card, .motion .kpi, .motion .projhit, .motion .chip, .motion .btn { transition: border-color .15s, background-color .15s, transform .15s, box-shadow .15s; }
|
||||||
|
.motion .card:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(0, 0, 0, .28); }
|
||||||
|
.motion .tabs button { transition: color .15s, border-color .2s; }
|
||||||
|
.motion .mbtn .mdot { background: var(--cyan); animation: mlive 2.2s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* Sheets on phones: at least 8 px to every edge, sitting low where the thumb is, scrolling inside when tall.
|
||||||
|
Above 700 px the sheet stays centred with 16 px to every edge (the .scrim and .sheet rules above). */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.scrim { align-items: flex-end; padding: 8px 8px max(8px, env(safe-area-inset-bottom, 0px)); }
|
||||||
|
.sheet { margin: auto 0 0; width: 100%; max-height: calc(100vh - 16px); max-height: calc(100dvh - 16px); padding: 16px; }
|
||||||
|
.sheet.narrow { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iPad landscape and narrow desktop windows. */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.board { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
|
||||||
|
.whboard { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
|
||||||
|
.kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.tw { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
.joblist { min-width: 860px; }
|
||||||
|
.pmtable { min-width: 720px; }
|
||||||
|
.fltrow .fltq { flex-basis: 100%; }
|
||||||
|
}
|
||||||
|
/* iPad portrait: the board columns stack, each with its own heading and scroll frame. */
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.board, .whboard { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.col { max-height: min(var(--col-max, 520px), calc(100vh - var(--toph, 64px) - 40px)); }
|
||||||
|
}
|
||||||
|
/* Phones. */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.col { max-height: none; }
|
||||||
|
.fltrow .f { flex: 1 1 calc(50% - 8px); min-width: 0; }
|
||||||
|
.fltrow .fltq, .fltrow .fltp { flex-basis: 100%; }
|
||||||
|
.fltbtns { flex: 1 1 100%; }
|
||||||
|
.fltbtns .seg { margin-left: auto; }
|
||||||
|
.fltrow2 .grow { display: none; }
|
||||||
|
.jlsort { min-width: 0; flex: 1 1 100%; }
|
||||||
|
.kpi .n { font-size: 34px; }
|
||||||
|
button.kpi::after { display: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, Depends, Request, Response
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .. import audit
|
from .. import audit, events
|
||||||
from ..auth import devices, signin, stepup
|
from ..auth import devices, signin, stepup
|
||||||
from ..auth import directory as userdir
|
from ..auth import directory as userdir
|
||||||
from ..auth import pin as pins
|
from ..auth import pin as pins
|
||||||
@@ -186,6 +186,7 @@ def _lock(d: Db, p: Principal, source: str) -> None:
|
|||||||
detail={"source": source},
|
detail={"source": source},
|
||||||
ip=p.ip,
|
ip=p.ip,
|
||||||
)
|
)
|
||||||
|
events.publish(conn, {"event": "session", "session_id": str(p.session_id), "action": "locked"})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lock", status_code=204)
|
@router.post("/lock", status_code=204)
|
||||||
@@ -240,5 +241,6 @@ def unlock(
|
|||||||
detail={"factor": body.factor},
|
detail={"factor": body.factor},
|
||||||
ip=p.ip,
|
ip=p.ip,
|
||||||
)
|
)
|
||||||
|
events.publish(conn, {"event": "session", "session_id": str(p.session_id), "action": "unlocked"})
|
||||||
p.session["locked_since"] = None
|
p.session["locked_since"] = None
|
||||||
return me_payload(conn, p)
|
return me_payload(conn, p)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from typing import Any
|
|||||||
from fastapi import Depends, Request, Response
|
from fastapi import Depends, Request, Response
|
||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
from .. import audit, db
|
from .. import audit, db, events
|
||||||
from ..auth import devices, sessions
|
from ..auth import devices, sessions
|
||||||
from ..auth import directory as userdir
|
from ..auth import directory as userdir
|
||||||
from ..auth import groups as groupmap
|
from ..auth import groups as groupmap
|
||||||
@@ -268,9 +268,13 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
|
|||||||
conn.execute("UPDATE sessions SET hidden_since = NULL WHERE id = %s", (row["id"],))
|
conn.execute("UPDATE sessions SET hidden_since = NULL WHERE id = %s", (row["id"],))
|
||||||
if lock_source is not None:
|
if lock_source is not None:
|
||||||
with conn.transaction():
|
with conn.transaction():
|
||||||
conn.execute(
|
locked = conn.execute(
|
||||||
"UPDATE sessions SET locked_since = %s WHERE id = %s AND locked_since IS NULL", (now, row["id"])
|
"UPDATE sessions SET locked_since = %s WHERE id = %s AND locked_since IS NULL RETURNING id",
|
||||||
)
|
(now, row["id"]),
|
||||||
|
).fetchone()
|
||||||
|
if locked is not None:
|
||||||
|
# Presence drops a locked session at once (events.EventHub handles the event).
|
||||||
|
events.publish(conn, {"event": "session", "session_id": str(row["id"]), "action": "locked"})
|
||||||
snap = row.get("roles_snapshot") or {}
|
snap = row.get("roles_snapshot") or {}
|
||||||
audit.append(
|
audit.append(
|
||||||
conn,
|
conn,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Query, Request
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from ..auth.groups import SCREENS
|
from ..auth.groups import SCREEN_LABELS, SCREENS, role_label
|
||||||
from ..events import EventHub, stream
|
from ..events import EventHub, stream
|
||||||
from .deps import resolve_standalone, settings_of
|
from .deps import resolve_standalone, settings_of
|
||||||
|
|
||||||
@@ -22,16 +22,21 @@ async def events(
|
|||||||
packet_id: int | None = Query(None, ge=1, le=2**62),
|
packet_id: int | None = Query(None, ge=1, le=2**62),
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""The event stream. `packet_id` is the packet the page has open; presence carries it, so the packet page can
|
"""The event stream. `packet_id` is the packet the page has open; presence carries it, so the packet page can
|
||||||
show who else is looking at the same packet. The client reopens the stream when the open packet changes."""
|
show who else is looking at the same packet. The client reopens the stream when the open packet changes.
|
||||||
|
Presence names the person with the label of the screen they have open (a screen their roles allow), else their
|
||||||
|
primary role. A locked session is left out of presence until it unlocks."""
|
||||||
# The session check runs on its own pooled connection, returned before the stream starts
|
# The session check runs on its own pooled connection, returned before the stream starts
|
||||||
p = await run_in_threadpool(resolve_standalone, request)
|
p = await run_in_threadpool(resolve_standalone, request)
|
||||||
hub: EventHub = request.app.state.events
|
hub: EventHub = request.app.state.events
|
||||||
|
open_screen = screen if screen in SCREENS and screen in p.screens else None
|
||||||
sub = hub.subscribe(
|
sub = hub.subscribe(
|
||||||
session_id=p.session_id,
|
session_id=p.session_id,
|
||||||
user_id=p.user_id,
|
user_id=p.user_id,
|
||||||
display_name=p.user["display_name"],
|
display_name=p.user["display_name"],
|
||||||
screen=screen if screen in SCREENS and screen in p.screens else None,
|
screen=open_screen,
|
||||||
packet_id=packet_id,
|
packet_id=packet_id,
|
||||||
|
role=SCREEN_LABELS[open_screen] if open_screen else role_label(p.roles),
|
||||||
|
locked=p.locked,
|
||||||
)
|
)
|
||||||
hello = {"session_id": str(p.session_id), "build": settings_of(request).ps_build}
|
hello = {"session_id": str(p.session_id), "build": settings_of(request).ps_build}
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ Every mutating answer is the full read model.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
@@ -18,7 +20,7 @@ from pydantic import BaseModel, Field
|
|||||||
from .. import audit, events, policy
|
from .. import audit, events, policy
|
||||||
from ..domain import assets as asset_store
|
from ..domain import assets as asset_store
|
||||||
from ..domain import ncr as ncrs
|
from ..domain import ncr as ncrs
|
||||||
from ..domain import packets, shop
|
from ..domain import packets, search, shop
|
||||||
from ..domain import qc as qc_dom
|
from ..domain import qc as qc_dom
|
||||||
from ..domain.pipeline import (
|
from ..domain.pipeline import (
|
||||||
Expect,
|
Expect,
|
||||||
@@ -294,32 +296,69 @@ def _pp(d: Db, p: Principal) -> policy.Principal:
|
|||||||
# ---------------------------------------------------------------- list and read
|
# ---------------------------------------------------------------- list and read
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
def site_today(request: Request) -> date:
|
||||||
def list_packets(
|
"""Today in the site time zone (settings.site_tz), once per request."""
|
||||||
p: Principal = Depends(current_principal), # noqa: B008
|
try:
|
||||||
d: Db = Depends(get_db), # noqa: B008
|
tz: Any = ZoneInfo(settings_of(request).site_tz)
|
||||||
|
except (ZoneInfoNotFoundError, ValueError):
|
||||||
|
tz = UTC
|
||||||
|
return datetime.now(tz).date()
|
||||||
|
|
||||||
|
|
||||||
|
def find_query(
|
||||||
|
q: str = Query("", max_length=200),
|
||||||
status: str = Query("", max_length=200),
|
status: str = Query("", max_length=200),
|
||||||
dept: str = Query("", max_length=10),
|
dept: str = Query("", max_length=10),
|
||||||
|
station_id: int | None = Query(None, ge=1, le=2**62),
|
||||||
|
welder_id: str = Query("", max_length=40),
|
||||||
|
flags: str = Query("", max_length=100),
|
||||||
|
flag: str = Query("", max_length=20),
|
||||||
|
sort: str = Query("", max_length=20),
|
||||||
|
mine_screen: str = Query("", max_length=20),
|
||||||
|
mine_station_id: int | None = Query(None, ge=1, le=2**62),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The find bar's query values, shared by GET /api/packets, the boards and the PM reports."""
|
||||||
|
return {
|
||||||
|
"q": q,
|
||||||
|
"status": status,
|
||||||
|
"dept": dept,
|
||||||
|
"station_id": station_id,
|
||||||
|
"welder_id": welder_id,
|
||||||
|
"flags": flags,
|
||||||
|
"flag": flag,
|
||||||
|
"sort": sort,
|
||||||
|
"mine_screen": mine_screen,
|
||||||
|
"mine_station_id": mine_station_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
FIND = Depends(find_query)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_packets(
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
unit_id: int | None = Query(None),
|
unit_id: int | None = Query(None),
|
||||||
project_id: int | None = Query(None),
|
project_id: int | None = Query(None),
|
||||||
q: str = Query("", max_length=200),
|
|
||||||
flag: str = Query("", max_length=20),
|
|
||||||
co: str = Query("", max_length=20),
|
co: str = Query("", max_length=20),
|
||||||
|
facets: int = Query(0, ge=0, le=1),
|
||||||
limit: int = Query(500, ge=1, le=2000),
|
limit: int = Query(500, ge=1, le=2000),
|
||||||
removed: int = Query(0, ge=0, le=1),
|
removed: int = Query(0, ge=0, le=1),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
filters = {
|
"""The cards for a list. The find bar's filters are applied here; `facets=1` adds the stations and workers
|
||||||
"status": status,
|
among the packets in the project and unit scope."""
|
||||||
"dept": dept.strip() or None,
|
pp = _pp(d, p)
|
||||||
"unit_id": unit_id,
|
filters = search.parse_filters(
|
||||||
"project_id": project_id,
|
{**find, "unit_id": unit_id, "project_id": project_id}, pp, screen=None, today=site_today(request)
|
||||||
"q": clean_text(q, 200),
|
)
|
||||||
"flag": flag,
|
filters.update({"co": co.strip().lower() or None, "limit": limit, "removed": bool(removed) and p.is_admin})
|
||||||
"co": co.strip().lower() or None,
|
out: dict[str, Any] = {"packets": packets.list_cards(d.conn, pp, filters)}
|
||||||
"limit": limit,
|
if facets:
|
||||||
"removed": bool(removed) and p.is_admin,
|
out["facets"] = search.facets(d.conn, project_id=project_id, unit_id=unit_id)
|
||||||
}
|
return out
|
||||||
return {"packets": packets.list_cards(d.conn, _pp(d, p), filters)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201, dependencies=WRITE)
|
@router.post("", status_code=201, dependencies=WRITE)
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""Project Management: the read-only reports, the CSV exports and the production status PDF.
|
||||||
|
|
||||||
|
Any signed-in session reads (every role may open every packet, PM included). Nothing here writes a packet; an export
|
||||||
|
writes one print_log row and one audit row (`report exported`). Every endpoint takes the find bar's scope: the
|
||||||
|
project and unit pick, dept, status, station_id, welder_id, flags, flag, q, and the history period (30, 90, 365 or
|
||||||
|
all days; default 90). Mine is ignored here. The reports read PostgreSQL only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from ..domain import packets, pm, prints
|
||||||
|
from ..pdf import pmreport
|
||||||
|
from ..pdf.tracecsv import to_csv
|
||||||
|
from .deps import Db, Principal, current_principal, get_db, settings_of
|
||||||
|
from .errors import ApiError
|
||||||
|
from .packets import FIND
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/pm", tags=["pm"])
|
||||||
|
|
||||||
|
PERIOD = Query(pm.DEFAULT_PERIOD, pattern="^(30|90|365|all)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _report(
|
||||||
|
request: Request,
|
||||||
|
d: Db,
|
||||||
|
p: Principal,
|
||||||
|
find: dict[str, Any],
|
||||||
|
project_id: int | None,
|
||||||
|
unit_id: int | None,
|
||||||
|
period: str,
|
||||||
|
expand: str | None = None,
|
||||||
|
) -> pm.Report:
|
||||||
|
conn = d.conn
|
||||||
|
pp = packets.policy_principal_for(conn, p)
|
||||||
|
s = settings_of(request)
|
||||||
|
return pm.load(
|
||||||
|
conn,
|
||||||
|
pp,
|
||||||
|
{**find, "project_id": project_id, "unit_id": unit_id},
|
||||||
|
now=datetime.now(UTC),
|
||||||
|
tz=s.site_tz,
|
||||||
|
period=period,
|
||||||
|
expand=expand,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overview")
|
||||||
|
def overview(
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
|
project_id: int | None = Query(None),
|
||||||
|
unit_id: int | None = Query(None),
|
||||||
|
period: str = PERIOD,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The KPIs, the plain-words summary, the pipeline and the units late or at risk."""
|
||||||
|
return pm.overview(_report(request, d, p, find, project_id, unit_id, period))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects")
|
||||||
|
def projects(
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
|
project_id: int | None = Query(None),
|
||||||
|
unit_id: int | None = Query(None),
|
||||||
|
period: str = PERIOD,
|
||||||
|
expand: str = Query("", max_length=2000),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The projects table. Without `expand` only the picked project shows its units; `expand=all` opens every
|
||||||
|
project, a comma list of project ids opens those."""
|
||||||
|
rep = _report(request, d, p, find, project_id, unit_id, period, expand=expand)
|
||||||
|
return {"scope": rep.scope(), "tables": [pm.projects_table(rep)]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/aging")
|
||||||
|
def aging(
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
|
project_id: int | None = Query(None),
|
||||||
|
unit_id: int | None = Query(None),
|
||||||
|
period: str = PERIOD,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
||||||
|
pipe = pm.pipeline(rep)
|
||||||
|
return {"scope": rep.scope(), "pipeline": pipe, "tables": pm.aging_tables(rep, pipe)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hours")
|
||||||
|
def hours(
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
|
project_id: int | None = Query(None),
|
||||||
|
unit_id: int | None = Query(None),
|
||||||
|
period: str = PERIOD,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
||||||
|
return {"scope": rep.scope(), "tables": pm.hours_tables(rep)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/quality")
|
||||||
|
def quality(
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
|
project_id: int | None = Query(None),
|
||||||
|
unit_id: int | None = Query(None),
|
||||||
|
period: str = PERIOD,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
||||||
|
return {"scope": rep.scope(), "tables": pm.quality_tables(rep)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/find")
|
||||||
|
def find_jobs(
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
|
project_id: int | None = Query(None),
|
||||||
|
unit_id: int | None = Query(None),
|
||||||
|
period: str = PERIOD,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The cards in scope, in the find bar's sort (default last updated)."""
|
||||||
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
||||||
|
if (find.get("sort") or "updated") != "project":
|
||||||
|
f = {**rep.filters, "sort": find.get("sort") or "updated", "limit": pm.CARD_LIMIT, "lite": True}
|
||||||
|
cards = packets.list_cards(d.conn, packets.policy_principal_for(d.conn, p), f)
|
||||||
|
else:
|
||||||
|
cards = rep.cards
|
||||||
|
return {"scope": rep.scope(), "packets": cards}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export.csv")
|
||||||
|
def export_csv(
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
|
project_id: int | None = Query(None),
|
||||||
|
unit_id: int | None = Query(None),
|
||||||
|
period: str = PERIOD,
|
||||||
|
table: str = Query("all", max_length=40),
|
||||||
|
) -> Response:
|
||||||
|
"""One table (by key), the jobs (`jobs`) or every report (`all`) as CSV for Excel: a BOM, CRLF lines and the
|
||||||
|
formula guard on every cell. Logged as `report exported`."""
|
||||||
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
||||||
|
found = pm.tables_for(rep, table.strip().lower())
|
||||||
|
if found is None:
|
||||||
|
raise ApiError(404, "not_found", "No such report table.")
|
||||||
|
name, tables = found
|
||||||
|
who = str(p.user.get("display_name") or p.user.get("sam_account") or "")
|
||||||
|
body = "\ufeff" + to_csv(pm.csv_rows(rep, name, tables, who)) + "\r\n"
|
||||||
|
day = rep.now.astimezone(pm.zone(rep.tz)).date().isoformat()
|
||||||
|
filename = f"PacketStation_{pm.file_stem(name)}_{day}.csv"
|
||||||
|
label = rep.scope_label()
|
||||||
|
prints.record(
|
||||||
|
d.conn,
|
||||||
|
p,
|
||||||
|
action="report exported",
|
||||||
|
kind="report-csv",
|
||||||
|
what=f"{name} · CSV · {label}",
|
||||||
|
pages=None,
|
||||||
|
detail={"name": name, "format": "csv", "scope": label, "period": rep.period},
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content=body.encode("utf-8"),
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": prints.disposition(filename, download=True), "Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report.pdf")
|
||||||
|
def report_pdf(
|
||||||
|
request: Request,
|
||||||
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
|
project_id: int | None = Query(None),
|
||||||
|
unit_id: int | None = Query(None),
|
||||||
|
period: str = PERIOD,
|
||||||
|
download: int = Query(0, ge=0, le=1),
|
||||||
|
) -> Response:
|
||||||
|
"""The PESCO production status report (dark format), stamped as a controlled copy on every page. Inline unless
|
||||||
|
`download=1`. Logged as `report exported` with the page count."""
|
||||||
|
rep = _report(request, d, p, find, project_id, unit_id, period)
|
||||||
|
who = prints.who_of(p)
|
||||||
|
built = pmreport.build(
|
||||||
|
d.conn,
|
||||||
|
settings_of(request),
|
||||||
|
scope_params={**find, "project_id": project_id, "unit_id": unit_id, "period": period},
|
||||||
|
who=who,
|
||||||
|
now=rep.now,
|
||||||
|
report=rep,
|
||||||
|
)
|
||||||
|
label = rep.scope_label()
|
||||||
|
prints.record(
|
||||||
|
d.conn,
|
||||||
|
p,
|
||||||
|
action="report exported",
|
||||||
|
kind="report-pdf",
|
||||||
|
what=f"Production status report · PDF · {label}",
|
||||||
|
pages=built.pages,
|
||||||
|
stamp=built.stamp,
|
||||||
|
stamp_sig=built.stamp_sig,
|
||||||
|
detail={"name": "Production status report", "format": "pdf", "scope": label, "period": rep.period},
|
||||||
|
)
|
||||||
|
day = rep.now.astimezone(pm.zone(rep.tz)).date().isoformat()
|
||||||
|
return Response(
|
||||||
|
content=built.pdf,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": prints.disposition(f"PESCO_Production_Status_{day}.pdf", download=bool(download)),
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -10,18 +10,18 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .. import audit, events, policy
|
from .. import audit, events, policy
|
||||||
from ..domain import assets as asset_store
|
from ..domain import assets as asset_store
|
||||||
from ..domain import itp as itp_dom
|
from ..domain import itp as itp_dom
|
||||||
from ..domain import packets, qcdocs
|
from ..domain import packets, qcdocs, search
|
||||||
from ..domain import qc as dom
|
from ..domain import qc as dom
|
||||||
from ..text import clean_opt, clean_text
|
from ..text import clean_opt, clean_text
|
||||||
from .deps import Db, Principal, current_principal, get_db
|
from .deps import Db, Principal, current_principal, get_db
|
||||||
from .errors import ApiError
|
from .errors import ApiError
|
||||||
from .packets import WRITE
|
from .packets import FIND, WRITE, site_today
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/qc", tags=["qc"])
|
router = APIRouter(prefix="/api/qc", tags=["qc"])
|
||||||
|
|
||||||
@@ -42,13 +42,21 @@ class QcDocBody(BaseModel):
|
|||||||
|
|
||||||
@router.get("/home")
|
@router.get("/home")
|
||||||
def home(
|
def home(
|
||||||
|
request: Request,
|
||||||
p: Principal = Depends(current_principal), # noqa: B008
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
d: Db = Depends(get_db), # noqa: B008
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
project_id: int | None = Query(None),
|
project_id: int | None = Query(None),
|
||||||
unit_id: int | None = Query(None),
|
unit_id: int | None = Query(None),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""The Q.C. home. The find bar values narrow the cards of the three boards; the KPIs follow the project and
|
||||||
|
unit only."""
|
||||||
conn = d.conn
|
conn = d.conn
|
||||||
return dom.home(conn, packets.policy_principal_for(conn, p), project_id=project_id, unit_id=unit_id)
|
pp = packets.policy_principal_for(conn, p)
|
||||||
|
filters = search.parse_filters(
|
||||||
|
{**find, "project_id": project_id, "unit_id": unit_id}, pp, screen="qc", today=site_today(request)
|
||||||
|
)
|
||||||
|
return dom.home(conn, pp, project_id=project_id, unit_id=unit_id, filters=filters)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/folders")
|
@router.get("/folders")
|
||||||
|
|||||||
@@ -8,26 +8,37 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
|
||||||
from ..domain import packets, shop
|
from ..domain import packets, search, shop
|
||||||
from ..text import clean_text
|
from ..text import clean_text
|
||||||
from .deps import Db, Principal, current_principal, get_db
|
from .deps import Db, Principal, current_principal, get_db
|
||||||
|
from .packets import FIND, site_today
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/shop", tags=["shop"])
|
router = APIRouter(prefix="/api/shop", tags=["shop"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/board")
|
@router.get("/board")
|
||||||
def board(
|
def board(
|
||||||
|
request: Request,
|
||||||
p: Principal = Depends(current_principal), # noqa: B008
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
d: Db = Depends(get_db), # noqa: B008
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
dept: str = Query("", max_length=10),
|
find: dict[str, Any] = FIND,
|
||||||
project_id: int | None = Query(None),
|
project_id: int | None = Query(None),
|
||||||
unit_id: int | None = Query(None),
|
unit_id: int | None = Query(None),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""The supervisor board. `dept` picks one of the departments the person leads (it scopes the board and its
|
||||||
|
KPIs); the other find bar values narrow the cards."""
|
||||||
conn = d.conn
|
conn = d.conn
|
||||||
pp = packets.policy_principal_for(conn, p)
|
pp = packets.policy_principal_for(conn, p)
|
||||||
return shop.board(conn, pp, dept=clean_text(dept, 10) or None, project_id=project_id, unit_id=unit_id)
|
dept = clean_text(find.get("dept") or "", 10) or None
|
||||||
|
filters = search.parse_filters(
|
||||||
|
{**find, "dept": None, "project_id": project_id, "unit_id": unit_id},
|
||||||
|
pp,
|
||||||
|
screen="supervisor",
|
||||||
|
today=site_today(request),
|
||||||
|
)
|
||||||
|
return shop.board(conn, pp, dept=dept, project_id=project_id, unit_id=unit_id, filters=filters)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stations")
|
@router.get("/stations")
|
||||||
@@ -41,9 +52,16 @@ def stations(
|
|||||||
|
|
||||||
@router.get("/stall")
|
@router.get("/stall")
|
||||||
def stall(
|
def stall(
|
||||||
|
request: Request,
|
||||||
p: Principal = Depends(current_principal), # noqa: B008
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
d: Db = Depends(get_db), # noqa: B008
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
station_id: int = Query(ge=1),
|
station_id: int = Query(ge=1),
|
||||||
|
find: dict[str, Any] = FIND,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""The cards at one station. The find bar values narrow them; `station_id` here is the stall's station, so the
|
||||||
|
Station filter does not apply. Mine is the station's own work."""
|
||||||
conn = d.conn
|
conn = d.conn
|
||||||
return shop.stall(conn, packets.policy_principal_for(conn, p), station_id)
|
pp = packets.policy_principal_for(conn, p)
|
||||||
|
raw = {**find, "station_id": None, "mine_station_id": find.get("mine_station_id") or station_id}
|
||||||
|
filters = search.parse_filters(raw, pp, screen="stall", today=site_today(request))
|
||||||
|
return shop.stall(conn, pp, station_id, filters)
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
|
||||||
from ..domain import packets
|
from ..domain import packets, search
|
||||||
from ..text import clean_text
|
|
||||||
from .deps import Db, Principal, current_principal, get_db, settings_of
|
from .deps import Db, Principal, current_principal, get_db, settings_of
|
||||||
|
from .packets import FIND
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/warehouse", tags=["warehouse"])
|
router = APIRouter(prefix="/api/warehouse", tags=["warehouse"])
|
||||||
|
|
||||||
@@ -141,21 +141,22 @@ def board(
|
|||||||
request: Request,
|
request: Request,
|
||||||
p: Principal = Depends(current_principal), # noqa: B008
|
p: Principal = Depends(current_principal), # noqa: B008
|
||||||
d: Db = Depends(get_db), # noqa: B008
|
d: Db = Depends(get_db), # noqa: B008
|
||||||
q: str = Query("", max_length=200),
|
find: dict[str, Any] = FIND,
|
||||||
project_id: int | None = Query(None),
|
project_id: int | None = Query(None),
|
||||||
unit_id: int | None = Query(None),
|
unit_id: int | None = Query(None),
|
||||||
dept: str = Query("", max_length=10),
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""The warehouse board. The find bar values narrow the cards of every column and the shorts list; the KPIs
|
||||||
|
follow the project and unit only."""
|
||||||
conn = d.conn
|
conn = d.conn
|
||||||
pp = packets.policy_principal_for(conn, p)
|
pp = packets.policy_principal_for(conn, p)
|
||||||
tz = _tz(request)
|
tz = _tz(request)
|
||||||
filters: dict[str, Any] = {
|
parsed = search.parse_filters(
|
||||||
"q": clean_text(q, 200),
|
{**find, "project_id": project_id, "unit_id": unit_id},
|
||||||
"project_id": project_id,
|
pp,
|
||||||
"unit_id": unit_id,
|
screen="warehouse",
|
||||||
"dept": dept.strip() or None,
|
today=datetime.now(tz).date(),
|
||||||
"limit": BOARD_LIMIT,
|
)
|
||||||
}
|
filters: dict[str, Any] = {**search.board_filters(parsed), "limit": BOARD_LIMIT}
|
||||||
|
|
||||||
columns: dict[str, list[dict[str, Any]]] = {st: [] for st in (*STAGES, "released")}
|
columns: dict[str, list[dict[str, Any]]] = {st: [] for st in (*STAGES, "released")}
|
||||||
for card in packets.list_cards(conn, pp, {**filters, "status": "warehouse"}):
|
for card in packets.list_cards(conn, pp, {**filters, "status": "warehouse"}):
|
||||||
@@ -182,4 +183,9 @@ def board(
|
|||||||
for sr in packets.rows(conn, SHORTS_SQL, (list(by_id),)):
|
for sr in packets.rows(conn, SHORTS_SQL, (list(by_id),)):
|
||||||
shorts.append(_short_row(sr, by_id[int(sr["packet_id"])], today, now))
|
shorts.append(_short_row(sr, by_id[int(sr["packet_id"])], today, now))
|
||||||
|
|
||||||
return {"kpis": _kpis(conn, filters), "columns": columns, "shorts": shorts}
|
return {
|
||||||
|
"kpis": _kpis(conn, filters),
|
||||||
|
"columns": columns,
|
||||||
|
"shorts": shorts,
|
||||||
|
"facets": search.facets(conn, project_id=project_id, unit_id=unit_id),
|
||||||
|
}
|
||||||
|
|||||||
@@ -228,6 +228,44 @@ def screens_for(roles: Iterable[str]) -> list[str]:
|
|||||||
return [s for s in SCREENS if s in have]
|
return [s for s in SCREENS if s in have]
|
||||||
|
|
||||||
|
|
||||||
|
# The label of a screen, shown beside a name in presence ("Also in this packet now: Ann (Q.C.)").
|
||||||
|
SCREEN_LABELS: dict[str, str] = {
|
||||||
|
"clerical": "Clerical",
|
||||||
|
"warehouse": "Warehouse",
|
||||||
|
"supervisor": "Supervisor",
|
||||||
|
"stall": "Weld Stall",
|
||||||
|
"qc": "Q.C.",
|
||||||
|
"pm": "Project Mgmt",
|
||||||
|
"admin": "Admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _role_text(role: str) -> str:
|
||||||
|
if role == "admin":
|
||||||
|
return "Admin"
|
||||||
|
if role.startswith("clerical"):
|
||||||
|
return "Clerical"
|
||||||
|
if role.startswith("warehouse"):
|
||||||
|
return "Warehouse"
|
||||||
|
if role.startswith("qc"):
|
||||||
|
return "Q.C."
|
||||||
|
if role == "pm":
|
||||||
|
return "Project Mgmt"
|
||||||
|
if role.startswith("shop:"):
|
||||||
|
return "Supervisor" if role.endswith((":lead", ":supervisor")) else "Weld Stall"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def role_label(roles: Iterable[str]) -> str:
|
||||||
|
"""The person's primary role as a screen label: the first role in map order (admin, Clerical, Warehouse, Q.C.,
|
||||||
|
PM, then the shop departments). Empty when there is none."""
|
||||||
|
for r in _default.sort_roles(roles):
|
||||||
|
text = _role_text(r)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def is_admin(roles: Iterable[str]) -> bool:
|
def is_admin(roles: Iterable[str]) -> bool:
|
||||||
return "admin" in set(roles)
|
return "admin" in set(roles)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from psycopg.rows import dict_row
|
|||||||
from .. import policy
|
from .. import policy
|
||||||
from ..api import deps
|
from ..api import deps
|
||||||
from ..api.errors import ApiError
|
from ..api.errors import ApiError
|
||||||
from . import checklists
|
from . import checklists, search
|
||||||
|
|
||||||
HAND_FLAG = "Made by hand - check it and send it on"
|
HAND_FLAG = "Made by hand - check it and send it on"
|
||||||
DRAWING_NEEDED_FLAG = "Made from VISUAL - drawing not attached yet"
|
DRAWING_NEEDED_FLAG = "Made from VISUAL - drawing not attached yet"
|
||||||
@@ -1246,7 +1246,7 @@ SELECT p.id, p.version, p.unit_id, u.lot, pr.number || u.lot AS unit_number, pr.
|
|||||||
st.name AS station_name, p.welder_id, wu.display_name AS welder_name, p.welder_stamp,
|
st.name AS station_name, p.welder_id, wu.display_name AS welder_name, p.welder_stamp,
|
||||||
p.routed_to::text AS routed_to, p.vclosed_at, p.vclosed_by, vu.display_name AS vclosed_name,
|
p.routed_to::text AS routed_to, p.vclosed_at, p.vclosed_by, vu.display_name AS vclosed_name,
|
||||||
p.source_kind::text AS source_kind, p.flags, p.drawing_state::text AS drawing_state, p.wh_skipped,
|
p.source_kind::text AS source_kind, p.flags, p.drawing_state::text AS drawing_state, p.wh_skipped,
|
||||||
p.heat_map, p.removed_at, coalesce(u.due, pr.due) AS due_effective, p.updated_at, p.visual,
|
p.heat_map, p.removed_at, coalesce(u.due, pr.due) AS due_effective, p.updated_at, p.created_at, p.visual,
|
||||||
p.visual_closed_at, p.visual_cancelled_at, p.wo_gone_at,
|
p.visual_closed_at, p.visual_cancelled_at, p.wo_gone_at,
|
||||||
EXISTS (SELECT 1 FROM change_orders c WHERE c.packet_id = p.id AND c.review = 'pending'
|
EXISTS (SELECT 1 FROM change_orders c WHERE c.packet_id = p.id AND c.review = 'pending'
|
||||||
AND c.origin IN ('visual', 'drawing')) AS co_pending,
|
AND c.origin IN ('visual', 'drawing')) AS co_pending,
|
||||||
@@ -1277,13 +1277,6 @@ LEFT JOIN LATERAL (
|
|||||||
) bc ON true
|
) bc ON true
|
||||||
"""
|
"""
|
||||||
|
|
||||||
FLAG_ON = ("1", "true", "yes", "any", "flagged")
|
|
||||||
FLAG_OFF = ("0", "false", "no", "none")
|
|
||||||
|
|
||||||
|
|
||||||
def _like(word: str) -> str:
|
|
||||||
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
|
||||||
|
|
||||||
|
|
||||||
def card_state(r: Mapping[str, Any], names: Mapping[str, str]) -> policy.PacketState:
|
def card_state(r: Mapping[str, Any], names: Mapping[str, str]) -> policy.PacketState:
|
||||||
"""PacketState from header columns and counts. Child-row facts the board does not load pass (the packet
|
"""PacketState from header columns and counts. Child-row facts the board does not load pass (the packet
|
||||||
@@ -1331,7 +1324,8 @@ def wh_card(r: Mapping[str, Any]) -> dict[str, Any] | None:
|
|||||||
|
|
||||||
|
|
||||||
def card_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
|
def card_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
|
||||||
"""The WHERE terms and parameters of the card filters, over CARD_SQL's aliases (p, u, pr)."""
|
"""The WHERE terms and parameters of the card filters, over CARD_SQL's aliases (p, u, pr, d, st, wu). The find
|
||||||
|
bar terms (status words, q, flag, flags, station_id, welder_id) come from domain/search.py."""
|
||||||
where: list[str] = []
|
where: list[str] = []
|
||||||
params: list[Any] = []
|
params: list[Any] = []
|
||||||
if filters.get("ids") is not None:
|
if filters.get("ids") is not None:
|
||||||
@@ -1339,7 +1333,7 @@ def card_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
|
|||||||
params.append([int(i) for i in filters["ids"]])
|
params.append([int(i) for i in filters["ids"]])
|
||||||
if not filters.get("removed"):
|
if not filters.get("removed"):
|
||||||
where.append("p.removed_at IS NULL")
|
where.append("p.removed_at IS NULL")
|
||||||
status = [s.strip() for s in str(filters.get("status") or "").split(",") if s.strip()]
|
status = search.status_terms(str(filters.get("status") or ""))
|
||||||
if status:
|
if status:
|
||||||
where.append("p.status::text = ANY(%s)")
|
where.append("p.status::text = ANY(%s)")
|
||||||
params.append(status)
|
params.append(status)
|
||||||
@@ -1352,55 +1346,29 @@ def card_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
|
|||||||
if filters.get("project_id") is not None:
|
if filters.get("project_id") is not None:
|
||||||
where.append("u.project_id = %s")
|
where.append("u.project_id = %s")
|
||||||
params.append(int(filters["project_id"]))
|
params.append(int(filters["project_id"]))
|
||||||
flag = str(filters.get("flag") or "").strip().lower()
|
|
||||||
if flag in FLAG_ON:
|
|
||||||
where.append(FLAG_SQL)
|
|
||||||
elif flag in FLAG_OFF:
|
|
||||||
where.append("NOT " + FLAG_SQL)
|
|
||||||
elif flag == "hand":
|
|
||||||
# Made by hand, or a VISUAL draft with a drawing or BOM uploaded by hand.
|
|
||||||
where.append("(p.status = 'draft' AND (p.source_kind = 'hand' OR p.flags && %s::text[]))")
|
|
||||||
params.append(list(HAND_UPLOAD_FLAGS))
|
|
||||||
elif flag in ("hand_upload", "hand_uploads"):
|
|
||||||
where.append("(p.status = 'draft' AND p.flags && %s::text[])")
|
|
||||||
params.append(list(HAND_UPLOAD_FLAGS))
|
|
||||||
elif flag and RX_FLAG_KIND.match(flag):
|
|
||||||
# One flag kind on the Flagged board (drafts). A visual draft whose flags are empty but whose drawing state
|
|
||||||
# still names the problem counts too, the same way flag_of reads it.
|
|
||||||
states = [st for st, k in STATE_KINDS.items() if k == flag]
|
|
||||||
where.append(
|
|
||||||
"(p.status = 'draft' AND (%s = ANY(p.flags) OR (cardinality(p.flags) = 0 AND p.source_kind = 'visual'"
|
|
||||||
" AND p.drawing_state::text = ANY(%s))))"
|
|
||||||
)
|
|
||||||
params += [flag, states]
|
|
||||||
co = str(filters.get("co") or "").strip().lower()
|
co = str(filters.get("co") or "").strip().lower()
|
||||||
if co == "pending":
|
if co == "pending":
|
||||||
where.append(CO_PENDING_SQL)
|
where.append(CO_PENDING_SQL)
|
||||||
for word in str(filters.get("q") or "").split()[:8]:
|
w, p = search.filter_where(filters)
|
||||||
pat = _like(word)
|
return where + w, params + p
|
||||||
where.append(
|
|
||||||
"(pr.number ILIKE %s OR pr.name ILIKE %s OR (pr.number || u.lot) ILIKE %s OR u.lot ILIKE %s"
|
|
||||||
" OR p.drawing ILIKE %s OR p.customer ILIKE %s OR pr.customer ILIKE %s OR pr.po ILIKE %s"
|
|
||||||
" OR p.title ILIKE %s OR EXISTS (SELECT 1 FROM packet_work_orders wo WHERE wo.packet_id = p.id"
|
|
||||||
" AND wo.wo_base ILIKE %s))"
|
|
||||||
)
|
|
||||||
params += [pat] * 10
|
|
||||||
return where, params
|
|
||||||
|
|
||||||
|
|
||||||
def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[str, Any]) -> list[dict[str, Any]]:
|
def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||||
"""The board cards. Filters: status (comma list), dept, unit_id, project_id, q, flag (1|0|hand|hand_upload|
|
"""The board cards. Filters: status (comma list of raw statuses or find bar words), dept, unit_id, project_id,
|
||||||
<kind>), co (pending), ids (packet ids), limit, removed."""
|
q, flag (a flag kind value or the old raw values), flags (quick flags; mine needs a MineScope in `mine`),
|
||||||
|
station_id, welder_id, co (pending), ids (packet ids), sort (updated|project|due|status|dept), limit, removed,
|
||||||
|
lite (skip `can` and `blocked`, for the reports)."""
|
||||||
where, params = card_where(filters)
|
where, params = card_where(filters)
|
||||||
sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "")
|
sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "")
|
||||||
sql += " ORDER BY p.updated_at DESC, p.id DESC LIMIT %s"
|
sql += " ORDER BY " + search.order_by(filters.get("sort")) + " LIMIT %s"
|
||||||
params.append(int(filters.get("limit") or 500))
|
params.append(int(filters.get("limit") or 500))
|
||||||
|
lite = bool(filters.get("lite"))
|
||||||
names = dept_labels(conn)
|
names = dept_labels(conn)
|
||||||
transitions = {str(a) for a in policy.TRANSITIONS}
|
transitions = {str(a) for a in policy.TRANSITIONS}
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for r in rows(conn, sql, params):
|
for r in rows(conn, sql, params):
|
||||||
state = card_state(r, names)
|
state = card_state(r, names)
|
||||||
allowed, blocked = policy.can(policy_principal, state)
|
allowed, blocked = ([], {}) if lite else policy.can(policy_principal, state)
|
||||||
welder = (
|
welder = (
|
||||||
None
|
None
|
||||||
if r["welder_id"] is None
|
if r["welder_id"] is None
|
||||||
@@ -1448,6 +1416,7 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
|
|||||||
"bom_missing_heat": int(r["bom_missing_heat"] or 0),
|
"bom_missing_heat": int(r["bom_missing_heat"] or 0),
|
||||||
"due_effective": r["due_effective"],
|
"due_effective": r["due_effective"],
|
||||||
"updated_at": r["updated_at"],
|
"updated_at": r["updated_at"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
"holder": policy.holder_name(state, r["dept_name"], r["station_name"]),
|
"holder": policy.holder_name(state, r["dept_name"], r["station_name"]),
|
||||||
"can": [str(a) for a in allowed if str(a) in transitions],
|
"can": [str(a) for a in allowed if str(a) in transitions],
|
||||||
"blocked": {str(a): v for a, v in blocked.items() if str(a) in transitions},
|
"blocked": {str(a): v for a, v in blocked.items() if str(a) in transitions},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -102,7 +102,7 @@ def record(
|
|||||||
conn: Any,
|
conn: Any,
|
||||||
p: deps.Principal,
|
p: deps.Principal,
|
||||||
*,
|
*,
|
||||||
action: Literal["print", "export"],
|
action: Literal["print", "export", "report exported"],
|
||||||
kind: str,
|
kind: str,
|
||||||
what: str,
|
what: str,
|
||||||
pages: int | None,
|
pages: int | None,
|
||||||
@@ -114,8 +114,9 @@ def record(
|
|||||||
stamp_sig: str | None = None,
|
stamp_sig: str | None = None,
|
||||||
detail: Mapping[str, Any] | None = None,
|
detail: Mapping[str, Any] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""One print_log row and one audit row (action `print` or `export`), in one transaction. The audit detail of an
|
"""One print_log row and one audit row (action `print`, `export`, or `report exported` for the PM reports, which
|
||||||
export names its kind as databook, traveler, csv or itp."""
|
belong to no packet), in one transaction. The audit detail of an export names its kind as databook, traveler,
|
||||||
|
csv or itp."""
|
||||||
audit_detail: dict[str, Any] = {
|
audit_detail: dict[str, Any] = {
|
||||||
"kind": EXPORT_KINDS.get(kind, kind) if action == "export" else kind,
|
"kind": EXPORT_KINDS.get(kind, kind) if action == "export" else kind,
|
||||||
"what": what,
|
"what": what,
|
||||||
@@ -127,6 +128,9 @@ def record(
|
|||||||
audit_detail["asset_id"] = str(asset_id)
|
audit_detail["asset_id"] = str(asset_id)
|
||||||
if itp_id is not None:
|
if itp_id is not None:
|
||||||
audit_detail["itp_id"] = itp_id
|
audit_detail["itp_id"] = itp_id
|
||||||
|
subject = "itp" if itp_id is not None and packet_id is None else "packet"
|
||||||
|
if action == "report exported":
|
||||||
|
subject = "report"
|
||||||
with conn.transaction():
|
with conn.transaction():
|
||||||
new_id = conn.execute(
|
new_id = conn.execute(
|
||||||
"INSERT INTO print_log (by, by_name, session_id, packet_id, unit_id, itp_id, kind, what, pages, asset_id,"
|
"INSERT INTO print_log (by, by_name, session_id, packet_id, unit_id, itp_id, kind, what, pages, asset_id,"
|
||||||
@@ -153,7 +157,7 @@ def record(
|
|||||||
user_id=p.user_id,
|
user_id=p.user_id,
|
||||||
session_id=p.session_id,
|
session_id=p.session_id,
|
||||||
role_snapshot=p.role_snapshot,
|
role_snapshot=p.role_snapshot,
|
||||||
subject_type="itp" if itp_id is not None and packet_id is None else "packet",
|
subject_type=subject,
|
||||||
subject_id=itp_id if itp_id is not None and packet_id is None else packet_id,
|
subject_id=itp_id if itp_id is not None and packet_id is None else packet_id,
|
||||||
packet_id=packet_id,
|
packet_id=packet_id,
|
||||||
detail=audit_detail,
|
detail=audit_detail,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from typing import Any
|
|||||||
|
|
||||||
from .. import policy
|
from .. import policy
|
||||||
from ..text import LIMITS
|
from ..text import LIMITS
|
||||||
from . import itp, packets, qcdocs
|
from . import itp, packets, qcdocs, search
|
||||||
|
|
||||||
A = policy.Action
|
A = policy.Action
|
||||||
|
|
||||||
@@ -128,6 +128,8 @@ LAST_SQL = (
|
|||||||
" WHERE h.packet_id = p.id ORDER BY h.id DESC LIMIT 1) h ON true"
|
" WHERE h.packet_id = p.id ORDER BY h.id DESC LIMIT 1) h ON true"
|
||||||
" WHERE p.removed_at IS NULL AND p.status::text = ANY(%s) AND h.result = %s"
|
" WHERE p.removed_at IS NULL AND p.status::text = ANY(%s) AND h.result = %s"
|
||||||
)
|
)
|
||||||
|
WAITING_SQL = "SELECT count(*) FROM packets p WHERE p.removed_at IS NULL AND p.status = 'qc'"
|
||||||
|
BOARD_LIMIT = 5000
|
||||||
COUNT_SQL = (
|
COUNT_SQL = (
|
||||||
"SELECT count(DISTINCT l.packet_id) FROM packet_log l JOIN packets p ON p.id = l.packet_id"
|
"SELECT count(DISTINCT l.packet_id) FROM packet_log l JOIN packets p ON p.id = l.packet_id"
|
||||||
" WHERE l.at > now() - interval '7 days' AND l.action LIKE %s AND p.removed_at IS NULL"
|
" WHERE l.at > now() - interval '7 days' AND l.action LIKE %s AND p.removed_at IS NULL"
|
||||||
@@ -147,29 +149,54 @@ def _count_7d(conn: Any, prefix: str, where: str, params: list[Any]) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def home(
|
def home(
|
||||||
conn: Any, pp: policy.Principal, *, project_id: int | None = None, unit_id: int | None = None
|
conn: Any,
|
||||||
|
pp: policy.Principal,
|
||||||
|
*,
|
||||||
|
project_id: int | None = None,
|
||||||
|
unit_id: int | None = None,
|
||||||
|
filters: Mapping[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""GET /api/qc/home: the KPIs, the three boards and the active project folders."""
|
"""GET /api/qc/home: the KPIs, the three boards and the active project folders. `filters` (the find bar's)
|
||||||
filters: dict[str, Any] = {"project_id": project_id, "unit_id": unit_id}
|
narrow the cards of the three boards; the KPIs follow the project and unit only."""
|
||||||
|
scope: dict[str, Any] = {"project_id": project_id, "unit_id": unit_id}
|
||||||
|
narrow = search.board_filters(filters or {})
|
||||||
|
narrow.update(scope)
|
||||||
ready = sorted(
|
ready = sorted(
|
||||||
packets.list_cards(conn, pp, {**filters, "status": "qc"}),
|
packets.list_cards(conn, pp, {**narrow, "status": "qc", "limit": BOARD_LIMIT}),
|
||||||
key=lambda c: (c["status_at"], c["id"]),
|
key=lambda c: (c["status_at"], c["id"]),
|
||||||
)
|
)
|
||||||
where, params = _scope_where(project_id, unit_id)
|
where, params = _scope_where(project_id, unit_id)
|
||||||
acc_ids = _ids_with_last(conn, ("complete", "filed"), "accepted", where, params)[:ACCEPTED_ON_BOARD]
|
acc_all = _ids_with_last(conn, ("complete", "filed"), "accepted", where, params)
|
||||||
|
if search.narrowing(narrow) and acc_all:
|
||||||
|
# The newest accepted packets that match the find bar (a light pass, no `can`), then the board's eight.
|
||||||
|
hits = {
|
||||||
|
c["id"]
|
||||||
|
for c in packets.list_cards(conn, pp, {**narrow, "ids": acc_all, "lite": True, "limit": BOARD_LIMIT})
|
||||||
|
}
|
||||||
|
acc_all = [i for i in acc_all if i in hits]
|
||||||
|
acc_ids = acc_all[:ACCEPTED_ON_BOARD]
|
||||||
rej_ids = _ids_with_last(conn, ("review",), "rejected", where, params)
|
rej_ids = _ids_with_last(conn, ("review",), "rejected", where, params)
|
||||||
by_id = {c["id"]: c for c in packets.list_cards(conn, pp, {"ids": acc_ids + rej_ids})} if acc_ids or rej_ids else {}
|
by_id: dict[Any, dict[str, Any]] = {}
|
||||||
|
if acc_ids or rej_ids:
|
||||||
|
by_id = {
|
||||||
|
c["id"]: c for c in packets.list_cards(conn, pp, {**narrow, "ids": acc_ids + rej_ids, "limit": BOARD_LIMIT})
|
||||||
|
}
|
||||||
accepted = [by_id[i] for i in acc_ids if i in by_id]
|
accepted = [by_id[i] for i in acc_ids if i in by_id]
|
||||||
rejected = [by_id[i] for i in rej_ids if i in by_id]
|
rejected = [by_id[i] for i in rej_ids if i in by_id]
|
||||||
_add_qc(conn, ready + accepted + rejected)
|
_add_qc(conn, ready + accepted + rejected)
|
||||||
|
waiting = len(ready)
|
||||||
|
if search.narrowing(filters or {}):
|
||||||
|
row = conn.execute(WAITING_SQL + where, params).fetchone() # where: fixed fragments from _scope_where
|
||||||
|
waiting = int(row[0] or 0)
|
||||||
return {
|
return {
|
||||||
"kpis": {
|
"kpis": {
|
||||||
"waiting": len(ready),
|
"waiting": waiting,
|
||||||
"accepted_7d": _count_7d(conn, "Q.C. accepted", where, params),
|
"accepted_7d": _count_7d(conn, "Q.C. accepted", where, params),
|
||||||
"rejected_7d": _count_7d(conn, "Q.C. rejected", where, params),
|
"rejected_7d": _count_7d(conn, "Q.C. rejected", where, params),
|
||||||
},
|
},
|
||||||
"columns": {"ready": ready, "accepted": accepted, "rejected": rejected},
|
"columns": {"ready": ready, "accepted": accepted, "rejected": rejected},
|
||||||
"folders": qcdocs.folders(conn, "active"),
|
"folders": qcdocs.folders(conn, "active"),
|
||||||
|
"facets": search.facets(conn, project_id=project_id, unit_id=unit_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
"""The find bar's search and filter terms, shared by every list: the boards, GET /api/packets and the PM reports.
|
||||||
|
|
||||||
|
A port of the old filters.js (FLT_STATUS, FLT_FLAGS, FLT_FLAGKIND, fltHay, mineFilter, fltFn, FLT_SORT), applied by
|
||||||
|
the server so a board never fetches more than it shows. Every term is SQL over CARD_SQL's aliases: p (packets),
|
||||||
|
u (units), pr (projects), d (departments), st (stations) and wu (the assigned welder). Request text only ever goes
|
||||||
|
in as a parameter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .. import policy
|
||||||
|
from ..auth.groups import SCREENS
|
||||||
|
from ..text import clean_text
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- vocabulary
|
||||||
|
|
||||||
|
OPEN_STATUSES = ("draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete")
|
||||||
|
|
||||||
|
# The find bar's status words. A raw status (or a comma list of them) keeps working.
|
||||||
|
FLT_STATUS: dict[str, tuple[str, ...]] = {
|
||||||
|
"draft": ("draft",),
|
||||||
|
"warehouse": ("warehouse",),
|
||||||
|
"ready": ("ready",),
|
||||||
|
"shop": ("ready", "assigned", "in_progress", "review", "qc"),
|
||||||
|
"station": ("assigned", "in_progress"),
|
||||||
|
"review": ("review",),
|
||||||
|
"qc": ("qc",),
|
||||||
|
"complete": ("complete",),
|
||||||
|
"filed": ("filed",),
|
||||||
|
"open": OPEN_STATUSES,
|
||||||
|
}
|
||||||
|
FLT_STATUS_LABELS: dict[str, str] = {
|
||||||
|
"draft": "Ready to release",
|
||||||
|
"warehouse": "With warehouse (any stage)",
|
||||||
|
"ready": "Ready to assign",
|
||||||
|
"shop": "In the shop (ready to Q.C.)",
|
||||||
|
"station": "At a station (assigned / working)",
|
||||||
|
"review": "Supervisor review",
|
||||||
|
"qc": "At Q.C.",
|
||||||
|
"complete": "Accepted / routed",
|
||||||
|
"filed": "Filed",
|
||||||
|
"open": "Everything not filed",
|
||||||
|
}
|
||||||
|
|
||||||
|
FLT_FLAGS = ("co", "short", "overdue", "week", "mine")
|
||||||
|
FLT_FLAG_LABELS: dict[str, str] = {
|
||||||
|
"co": "Change order open",
|
||||||
|
"short": "Shorts open",
|
||||||
|
"overdue": "Project overdue",
|
||||||
|
"week": "Due within 7 days",
|
||||||
|
"mine": "Mine",
|
||||||
|
}
|
||||||
|
|
||||||
|
# The Flag select (drafts only): value -> rule name.
|
||||||
|
FLT_FLAGKIND: dict[str, str] = {
|
||||||
|
"any": "flagged",
|
||||||
|
"rev": "rev_mismatch",
|
||||||
|
"unconf": "rev_unconfirmed",
|
||||||
|
"hand": "hand",
|
||||||
|
"none": "not_released",
|
||||||
|
"vault": "not_found",
|
||||||
|
"read": "unreadable",
|
||||||
|
"wait": "drawing_needed",
|
||||||
|
"no": "not_flagged",
|
||||||
|
}
|
||||||
|
FLT_FLAGKIND_LABELS: dict[str, str] = {
|
||||||
|
"any": "Flagged (any reason)",
|
||||||
|
"rev": "Rev mismatch with M-Files",
|
||||||
|
"unconf": "Rev not confirmed yet",
|
||||||
|
"hand": "Made or uploaded by hand",
|
||||||
|
"none": "Still in drafting",
|
||||||
|
"vault": "Drawing not found in M-Files",
|
||||||
|
"read": "Drawing couldn't be read",
|
||||||
|
"wait": "Drawing being looked up",
|
||||||
|
"no": "Not flagged",
|
||||||
|
}
|
||||||
|
# The old raw values (phase 2 to 7) that keep working.
|
||||||
|
FLAG_ON = ("1", "true", "yes", "flagged")
|
||||||
|
FLAG_OFF = ("0", "false")
|
||||||
|
|
||||||
|
# The status texts of the frontend's lib/packets.ts STATUS_LABEL (search matches these; the PM history groups by
|
||||||
|
# them).
|
||||||
|
STATUS_LABEL: dict[str, str] = {
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
|
||||||
|
TIE = "p.updated_at DESC, p.id DESC"
|
||||||
|
SORTS: dict[str, str] = {
|
||||||
|
"updated": TIE,
|
||||||
|
"project": 'pr.number COLLATE "C", u.lot COLLATE "C", p.op COLLATE "C", p.id',
|
||||||
|
"due": f"coalesce(u.due, pr.due) ASC NULLS LAST, {TIE}",
|
||||||
|
"status": f"p.status, {TIE}",
|
||||||
|
"dept": f"p.dept_code, {TIE}",
|
||||||
|
}
|
||||||
|
SORT_LABELS: dict[str, str] = {
|
||||||
|
"updated": "Last updated",
|
||||||
|
"project": "Project / lot",
|
||||||
|
"due": "Due date",
|
||||||
|
"status": "Status",
|
||||||
|
"dept": "Department",
|
||||||
|
}
|
||||||
|
|
||||||
|
WORDS_MAX = 8
|
||||||
|
WORD_MAX = 60
|
||||||
|
# Words that match everything, so "lot 52" and "rev B" work as the old hay text did.
|
||||||
|
WORDS_ALWAYS = ("lot", "rev")
|
||||||
|
MINE_SCREENS = ("clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin")
|
||||||
|
LEAD = 2
|
||||||
|
|
||||||
|
|
||||||
|
def like(word: str) -> str:
|
||||||
|
"""An ILIKE pattern for a word anywhere, with %, _ and the backslash escaped."""
|
||||||
|
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- status
|
||||||
|
|
||||||
|
|
||||||
|
def status_terms(value: str) -> list[str]:
|
||||||
|
"""The raw statuses a status value stands for: a find bar word expands, a raw status stays, in a comma list."""
|
||||||
|
out: list[str] = []
|
||||||
|
for part in str(value or "").split(","):
|
||||||
|
s = part.strip().lower()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
for st in FLT_STATUS.get(s, (s,)):
|
||||||
|
if st not in out:
|
||||||
|
out.append(st)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- search
|
||||||
|
|
||||||
|
_STATUS_CASE = (
|
||||||
|
"(CASE p.status::text "
|
||||||
|
+ " ".join(f"WHEN '{k}' THEN '{v}'" for k, v in STATUS_LABEL.items())
|
||||||
|
+ " ELSE p.status::text END)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# One fragment per field, each with exactly one %s (the word's pattern).
|
||||||
|
_FIELDS: tuple[str, ...] = (
|
||||||
|
"pr.number ILIKE %s",
|
||||||
|
"pr.name ILIKE %s",
|
||||||
|
"(pr.number || u.lot) ILIKE %s",
|
||||||
|
"(pr.number || ltrim(u.lot, '0')) ILIKE %s",
|
||||||
|
"u.lot ILIKE %s",
|
||||||
|
"ltrim(u.lot, '0') ILIKE %s",
|
||||||
|
"u.name ILIKE %s",
|
||||||
|
"p.op ILIKE %s",
|
||||||
|
"p.title ILIKE %s",
|
||||||
|
"p.drawing ILIKE %s",
|
||||||
|
"p.rev ILIKE %s",
|
||||||
|
"p.wps ILIKE %s",
|
||||||
|
"p.customer ILIKE %s",
|
||||||
|
"pr.customer ILIKE %s",
|
||||||
|
"pr.po ILIKE %s",
|
||||||
|
"d.name ILIKE %s",
|
||||||
|
"st.name ILIKE %s",
|
||||||
|
"wu.display_name ILIKE %s",
|
||||||
|
"p.welder_stamp ILIKE %s",
|
||||||
|
"wu.welder_stamp ILIKE %s",
|
||||||
|
_STATUS_CASE + " ILIKE %s",
|
||||||
|
"array_to_string(p.flags, ' ') ILIKE %s",
|
||||||
|
"EXISTS (SELECT 1 FROM project_aliases pa WHERE pa.project_id = pr.id"
|
||||||
|
" AND (pa.unit_id IS NULL OR pa.unit_id = u.id) AND pa.alias ILIKE %s)",
|
||||||
|
"EXISTS (SELECT 1 FROM packet_work_orders wo WHERE wo.packet_id = p.id"
|
||||||
|
" AND (wo.wo_base || '/' || wo.lot || '/' || wo.split || '/' || wo.sub) ILIKE %s)",
|
||||||
|
"EXISTS (SELECT 1 FROM bom_lines b WHERE b.packet_id = p.id"
|
||||||
|
" AND concat_ws(' ', b.heat, b.part_id, b.description, b.material) ILIKE %s)",
|
||||||
|
"EXISTS (SELECT 1 FROM change_orders c WHERE c.packet_id = p.id"
|
||||||
|
" AND concat_ws(' ', 'CO-' || c.co_no, c.ecn) ILIKE %s)",
|
||||||
|
"EXISTS (SELECT 1 FROM welds w LEFT JOIN users ww ON ww.id = w.welder_id WHERE w.packet_id = p.id"
|
||||||
|
" AND w.removed_at IS NULL AND concat_ws(' ', w.stamp, ww.display_name) ILIKE %s)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_words(q: str) -> list[str]:
|
||||||
|
"""The words of a search: at most 8, each cut at 60 characters, with "lot" and "rev" left out."""
|
||||||
|
words = [w[:WORD_MAX] for w in clean_text(q or "", 1000).split()[:WORDS_MAX]]
|
||||||
|
return [w for w in words if w.lower() not in WORDS_ALWAYS]
|
||||||
|
|
||||||
|
|
||||||
|
def search_terms(q: str) -> tuple[list[str], list[Any]]:
|
||||||
|
"""One "(...)" term per word; every word must match some field. Child matches are EXISTS terms, so a packet
|
||||||
|
is never listed twice."""
|
||||||
|
from .packets import FLAG_SQL
|
||||||
|
|
||||||
|
where: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
for word in search_words(q):
|
||||||
|
pat = like(word)
|
||||||
|
parts = list(_FIELDS)
|
||||||
|
# The word "flagged" (or a part of it) finds every flagged draft, as the old hay text did.
|
||||||
|
parts.append(f"({FLAG_SQL} AND 'flagged' ILIKE %s)")
|
||||||
|
where.append("(" + " OR ".join(parts) + ")")
|
||||||
|
params += [pat] * len(parts)
|
||||||
|
return where, params
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Mine
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MineScope:
|
||||||
|
screen: str
|
||||||
|
principal: policy.Principal
|
||||||
|
station_id: int | None
|
||||||
|
|
||||||
|
|
||||||
|
def screens_of(pp: policy.Principal) -> list[str]:
|
||||||
|
"""The screens a policy principal may open, in SCREENS order (admin: every screen)."""
|
||||||
|
if pp.admin:
|
||||||
|
return list(SCREENS)
|
||||||
|
have: set[str] = set()
|
||||||
|
if "CL" in pp.support:
|
||||||
|
have.add("clerical")
|
||||||
|
if "WH" in pp.support:
|
||||||
|
have.add("warehouse")
|
||||||
|
if "QC" in pp.support:
|
||||||
|
have.add("qc")
|
||||||
|
if pp.pm:
|
||||||
|
have.add("pm")
|
||||||
|
for lvl in pp.shop.values():
|
||||||
|
have.add("stall")
|
||||||
|
if int(lvl) >= LEAD:
|
||||||
|
have.add("supervisor")
|
||||||
|
return [s for s in SCREENS if s in have]
|
||||||
|
|
||||||
|
|
||||||
|
def mine_scope(pp: policy.Principal, screen: str | None, station_id: int | None) -> MineScope:
|
||||||
|
"""The screen Mine is decided for: the one the caller names, else the first screen the roles give."""
|
||||||
|
s = str(screen or "").strip().lower()
|
||||||
|
if s not in MINE_SCREENS:
|
||||||
|
found = screens_of(pp)
|
||||||
|
s = found[0] if found else "pm"
|
||||||
|
return MineScope(screen=s, principal=pp, station_id=station_id)
|
||||||
|
|
||||||
|
|
||||||
|
def mine_term(m: MineScope) -> tuple[str | None, list[Any]]:
|
||||||
|
"""The Mine rule (the old mineFilter) for the screen, or None when it is everything (PM, admin)."""
|
||||||
|
pp = m.principal
|
||||||
|
if m.screen == "clerical":
|
||||||
|
return "(p.status = 'draft' OR (p.status = 'complete' AND p.routed_to = 'databook'))", []
|
||||||
|
if m.screen == "warehouse":
|
||||||
|
return (
|
||||||
|
"(p.status = 'warehouse' OR EXISTS (SELECT 1 FROM bom_shorts ms WHERE ms.packet_id = p.id"
|
||||||
|
" AND ms.closed_at IS NULL))",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
if m.screen == "qc":
|
||||||
|
return "p.status = 'qc'", []
|
||||||
|
if m.screen == "supervisor":
|
||||||
|
depts = sorted(code for code, lvl in pp.shop.items() if int(lvl) >= LEAD)
|
||||||
|
if not depts:
|
||||||
|
return (None, []) if pp.admin else ("false", [])
|
||||||
|
return "p.dept_code = ANY(%s)", [depts]
|
||||||
|
if m.screen == "stall":
|
||||||
|
depts = sorted(pp.shop)
|
||||||
|
if m.station_id is None:
|
||||||
|
return "false", []
|
||||||
|
if not depts and pp.admin:
|
||||||
|
return "p.station_id = %s", [int(m.station_id)]
|
||||||
|
return "(p.dept_code = ANY(%s) AND p.station_id = %s)", [depts, int(m.station_id)]
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- quick flags and flag kinds
|
||||||
|
|
||||||
|
|
||||||
|
def flag_terms(flags: Iterable[str], *, today: date, mine: MineScope | None) -> tuple[list[str], list[Any]]:
|
||||||
|
"""The quick flag chips: every chip on must hold."""
|
||||||
|
where: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
for f in flags:
|
||||||
|
if f == "co":
|
||||||
|
where.append("EXISTS (SELECT 1 FROM v_open_cos fc WHERE fc.packet_id = p.id)")
|
||||||
|
elif f == "short":
|
||||||
|
where.append("EXISTS (SELECT 1 FROM bom_shorts fs WHERE fs.packet_id = p.id AND fs.closed_at IS NULL)")
|
||||||
|
elif f == "overdue":
|
||||||
|
where.append("coalesce(u.due, pr.due) < %s")
|
||||||
|
params.append(today)
|
||||||
|
elif f == "week":
|
||||||
|
where.append("coalesce(u.due, pr.due) <= %s")
|
||||||
|
params.append(today + timedelta(days=7))
|
||||||
|
elif f == "mine" and mine is not None:
|
||||||
|
term, p = mine_term(mine)
|
||||||
|
if term is not None:
|
||||||
|
where.append(term)
|
||||||
|
params += p
|
||||||
|
return where, params
|
||||||
|
|
||||||
|
|
||||||
|
def _kind_term(kinds: list[str]) -> tuple[str, list[Any]]:
|
||||||
|
"""Drafts carrying one of the flag kinds. A visual draft whose flags are empty but whose drawing state still
|
||||||
|
names the problem counts too, the same way flag_of reads it."""
|
||||||
|
from .packets import STATE_KINDS
|
||||||
|
|
||||||
|
states = [st for st, k in STATE_KINDS.items() if k in kinds]
|
||||||
|
return (
|
||||||
|
"(p.status = 'draft' AND (p.flags && %s::text[] OR (cardinality(p.flags) = 0 AND p.source_kind = 'visual'"
|
||||||
|
" AND p.drawing_state::text = ANY(%s))))",
|
||||||
|
[kinds, states],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def flagkind_term(value: str) -> tuple[str, list[Any]] | None:
|
||||||
|
"""The Flag select's term, or None for no filter. The old raw values keep working."""
|
||||||
|
from .packets import FLAG_SQL, HAND_UPLOAD_FLAGS, RX_FLAG_KIND
|
||||||
|
|
||||||
|
v = str(value or "").strip().lower()
|
||||||
|
if not v or v == "all":
|
||||||
|
return None
|
||||||
|
if v == "any" or v in FLAG_ON:
|
||||||
|
return FLAG_SQL, []
|
||||||
|
if v == "no" or v in FLAG_OFF:
|
||||||
|
return "NOT " + FLAG_SQL, []
|
||||||
|
if v == "hand":
|
||||||
|
return "(p.status = 'draft' AND (p.source_kind = 'hand' OR p.flags && %s::text[]))", [list(HAND_UPLOAD_FLAGS)]
|
||||||
|
if v in ("hand_upload", "hand_uploads"):
|
||||||
|
return "(p.status = 'draft' AND p.flags && %s::text[])", [list(HAND_UPLOAD_FLAGS)]
|
||||||
|
if v == "unconf":
|
||||||
|
# The drawing is attached but the drawing job has not confirmed the rev with M-Files yet (the read model
|
||||||
|
# keeps that in visual->>'rev_confirmed').
|
||||||
|
return (
|
||||||
|
"(p.status = 'draft' AND p.source_kind = 'visual' AND p.drawing_state = 'attached'"
|
||||||
|
" AND (p.visual->>'rev_confirmed') IS DISTINCT FROM 'true')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
if v == "rev":
|
||||||
|
return _kind_term(["rev_mismatch"])
|
||||||
|
if v == "none":
|
||||||
|
return _kind_term(["not_released"])
|
||||||
|
if v == "vault":
|
||||||
|
return _kind_term(["not_found"])
|
||||||
|
if v == "read":
|
||||||
|
return _kind_term(["lookup_failed", "bad_rev"])
|
||||||
|
if v == "wait":
|
||||||
|
return _kind_term(["drawing_needed"])
|
||||||
|
if RX_FLAG_KIND.match(v):
|
||||||
|
return _kind_term([v])
|
||||||
|
return "false", []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- sort and facets
|
||||||
|
|
||||||
|
|
||||||
|
def order_by(sort: str | None) -> str:
|
||||||
|
return SORTS.get(str(sort or "").strip().lower(), TIE)
|
||||||
|
|
||||||
|
|
||||||
|
# {scope} is fixed terms from _scope, never request text.
|
||||||
|
FACET_STATIONS_SQL = """
|
||||||
|
SELECT DISTINCT st.id, st.name, st.dept_code FROM packets p JOIN units u ON u.id = p.unit_id
|
||||||
|
JOIN stations st ON st.id = p.station_id WHERE p.removed_at IS NULL{scope} ORDER BY st.name, st.id
|
||||||
|
"""
|
||||||
|
FACET_WORKERS_SQL = """
|
||||||
|
SELECT DISTINCT wu.id, wu.display_name FROM packets p JOIN units u ON u.id = p.unit_id
|
||||||
|
JOIN users wu ON wu.id = p.welder_id WHERE p.removed_at IS NULL{scope} ORDER BY wu.display_name, wu.id
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _scope(project_id: int | None, unit_id: int | None, ids: list[int] | None) -> tuple[str, list[Any]]:
|
||||||
|
terms = ""
|
||||||
|
params: list[Any] = []
|
||||||
|
if project_id is not None:
|
||||||
|
terms += " AND u.project_id = %s"
|
||||||
|
params.append(int(project_id))
|
||||||
|
if unit_id is not None:
|
||||||
|
terms += " AND p.unit_id = %s"
|
||||||
|
params.append(int(unit_id))
|
||||||
|
if ids is not None:
|
||||||
|
terms += " AND p.id = ANY(%s)"
|
||||||
|
params.append([int(i) for i in ids])
|
||||||
|
return terms, params
|
||||||
|
|
||||||
|
|
||||||
|
def facets(
|
||||||
|
conn: Any, *, project_id: int | None = None, unit_id: int | None = None, ids: Iterable[int] | None = None
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""The distinct stations and assigned welders among the packets in the project and unit scope (and, for a
|
||||||
|
board, among the board's own packets), ordered by name. The other filters do not narrow them."""
|
||||||
|
from .packets import rows
|
||||||
|
|
||||||
|
scope, params = _scope(project_id, unit_id, None if ids is None else list(ids))
|
||||||
|
stations = rows(conn, FACET_STATIONS_SQL.format(scope=scope), params)
|
||||||
|
workers = rows(conn, FACET_WORKERS_SQL.format(scope=scope), params)
|
||||||
|
return {
|
||||||
|
"stations": [{"id": r["id"], "name": r["name"], "dept_code": r["dept_code"]} for r in stations],
|
||||||
|
"workers": [{"id": str(r["id"]), "display_name": r["display_name"]} for r in workers],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the filters dict
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or_none(v: Any) -> int | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_text(v: Any) -> str | None:
|
||||||
|
"""A welder id as uuid text; anything that is not a uuid becomes a value no packet has."""
|
||||||
|
s = str(v or "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(uuid.UUID(s))
|
||||||
|
except ValueError:
|
||||||
|
return "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_flags(value: Any) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
for part in str(value or "").split(","):
|
||||||
|
f = part.strip().lower()
|
||||||
|
if f in FLT_FLAGS and f not in out:
|
||||||
|
out.append(f)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_filters(
|
||||||
|
params: Mapping[str, Any], pp: policy.Principal, *, screen: str | None, today: date | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The filters dict list_cards takes, from the find bar's query values. `screen` is the default for Mine (the
|
||||||
|
board's own screen); `mine_screen` in the params wins when given."""
|
||||||
|
flags = parse_flags(params.get("flags"))
|
||||||
|
mine_station = _int_or_none(params.get("mine_station_id"))
|
||||||
|
mine = mine_scope(pp, str(params.get("mine_screen") or "") or screen, mine_station) if "mine" in flags else None
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"status": str(params.get("status") or "").strip(),
|
||||||
|
"dept": (str(params.get("dept") or "").strip() or None),
|
||||||
|
"unit_id": _int_or_none(params.get("unit_id")),
|
||||||
|
"project_id": _int_or_none(params.get("project_id")),
|
||||||
|
"q": clean_text(params.get("q") or "", 200),
|
||||||
|
"flag": str(params.get("flag") or "").strip().lower(),
|
||||||
|
"flags": flags,
|
||||||
|
"station_id": _int_or_none(params.get("station_id")),
|
||||||
|
"welder_id": _uuid_text(params.get("welder_id")),
|
||||||
|
"sort": str(params.get("sort") or "updated").strip().lower(),
|
||||||
|
"mine": mine,
|
||||||
|
"today": today or date.today(),
|
||||||
|
}
|
||||||
|
if out["dept"] and out["dept"].lower() == "all":
|
||||||
|
out["dept"] = None
|
||||||
|
if out["status"].lower() == "all":
|
||||||
|
out["status"] = ""
|
||||||
|
if out["flag"] == "all":
|
||||||
|
out["flag"] = ""
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def narrowing(filters: Mapping[str, Any]) -> bool:
|
||||||
|
"""True when a filter other than the project and unit pick is set (the old fset)."""
|
||||||
|
return bool(
|
||||||
|
filters.get("q")
|
||||||
|
or filters.get("status")
|
||||||
|
or filters.get("also_status")
|
||||||
|
or filters.get("dept")
|
||||||
|
or filters.get("flag")
|
||||||
|
or filters.get("flags")
|
||||||
|
or filters.get("station_id") is not None
|
||||||
|
or filters.get("welder_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
|
||||||
|
"""The find bar terms of a filters dict: q, the user's status (`also_status` on a board, whose own status is
|
||||||
|
fixed), station, welder, the quick flags (Mine included) and the flag kind."""
|
||||||
|
where: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
also = status_terms(str(filters.get("also_status") or ""))
|
||||||
|
if also:
|
||||||
|
where.append("p.status::text = ANY(%s)")
|
||||||
|
params.append(also)
|
||||||
|
if filters.get("station_id") is not None:
|
||||||
|
where.append("p.station_id = %s")
|
||||||
|
params.append(int(filters["station_id"]))
|
||||||
|
if filters.get("welder_id"):
|
||||||
|
where.append("p.welder_id = %s::uuid")
|
||||||
|
params.append(str(filters["welder_id"]))
|
||||||
|
flags = list(filters.get("flags") or [])
|
||||||
|
if flags:
|
||||||
|
today = filters.get("today") or date.today()
|
||||||
|
mine = filters.get("mine")
|
||||||
|
w, p = flag_terms(flags, today=today, mine=mine if isinstance(mine, MineScope) else None)
|
||||||
|
where += w
|
||||||
|
params += p
|
||||||
|
fk = flagkind_term(str(filters.get("flag") or ""))
|
||||||
|
if fk is not None:
|
||||||
|
where.append(fk[0])
|
||||||
|
params += fk[1]
|
||||||
|
w, p = search_terms(str(filters.get("q") or ""))
|
||||||
|
where += w
|
||||||
|
params += p
|
||||||
|
return where, params
|
||||||
|
|
||||||
|
|
||||||
|
def board_filters(filters: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""The filters for a board whose columns fix their own statuses: the user's status moves to `also_status` and
|
||||||
|
the project, unit and limit are left to the board."""
|
||||||
|
out = dict(filters)
|
||||||
|
out["also_status"] = out.pop("status", "") or ""
|
||||||
|
out.pop("sort", None)
|
||||||
|
return out
|
||||||
@@ -12,7 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
from .. import policy
|
from .. import policy
|
||||||
from ..api.errors import ApiError
|
from ..api.errors import ApiError
|
||||||
from . import packets
|
from . import packets, search
|
||||||
|
|
||||||
LEAD, SUPERVISOR = 2, 3
|
LEAD, SUPERVISOR = 2, 3
|
||||||
LEVEL_NAMES = {1: "worker", 2: "lead", 3: "supervisor"}
|
LEVEL_NAMES = {1: "worker", 2: "lead", 3: "supervisor"}
|
||||||
@@ -110,6 +110,15 @@ WHERE p.id = ANY(%s)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
KPI_SQL = """
|
||||||
|
SELECT p.id, p.status::text AS status, s.clock_elapsed_ms, s.clock_running_since, s.clock_started_at,
|
||||||
|
(SELECT coalesce(sum(w.target_hrs), 0) FROM packet_work_orders w WHERE w.packet_id = p.id) AS target_hrs
|
||||||
|
FROM packets p
|
||||||
|
LEFT JOIN packet_stages s ON s.packet_id = p.id AND s.closed_at IS NULL
|
||||||
|
WHERE p.id = ANY(%s)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def add_shop_fields(conn: Any, cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def add_shop_fields(conn: Any, cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Add the shop fields to list_cards cards, in place: welder_stamp, clock {elapsed_ms, running}, target_hrs,
|
"""Add the shop fields to list_cards cards, in place: welder_stamp, clock {elapsed_ms, running}, target_hrs,
|
||||||
open_cos [{co_no, label, review}] and return_note."""
|
open_cos [{co_no, label, review}] and return_note."""
|
||||||
@@ -215,8 +224,12 @@ def board(
|
|||||||
dept: str | None = None,
|
dept: str | None = None,
|
||||||
project_id: int | None = None,
|
project_id: int | None = None,
|
||||||
unit_id: int | None = None,
|
unit_id: int | None = None,
|
||||||
|
filters: Mapping[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""GET /api/shop/board. 403 forbidden when the principal leads no department, or names one outside the scope."""
|
"""GET /api/shop/board. 403 forbidden when the principal leads no department, or names one outside the scope.
|
||||||
|
|
||||||
|
`filters` are the find bar's (search.parse_filters): they narrow the cards of every column and the station
|
||||||
|
grid. The KPIs follow the department, project and unit only."""
|
||||||
scope = board_scope(conn, pp)
|
scope = board_scope(conn, pp)
|
||||||
if not scope:
|
if not scope:
|
||||||
raise ApiError(403, "forbidden", MSG_NO_LEAD)
|
raise ApiError(403, "forbidden", MSG_NO_LEAD)
|
||||||
@@ -233,7 +246,8 @@ def board(
|
|||||||
{"depts": codes, "statuses": list(BOARD_STATUSES), "limit": BOARD_LIMIT, **fparams},
|
{"depts": codes, "statuses": list(BOARD_STATUSES), "limit": BOARD_LIMIT, **fparams},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
cards = _cards(conn, pp, ids)
|
narrow = search.narrowing(filters or {})
|
||||||
|
cards = _cards(conn, pp, ids, **search.board_filters(filters or {}))
|
||||||
|
|
||||||
columns: dict[str, list[dict[str, Any]]] = {k: [] for k in COLUMNS.values()}
|
columns: dict[str, list[dict[str, Any]]] = {k: [] for k in COLUMNS.values()}
|
||||||
at_station: list[dict[str, Any]] = []
|
at_station: list[dict[str, Any]] = []
|
||||||
@@ -290,6 +304,9 @@ def board(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if narrow:
|
||||||
|
kpis = _board_kpis(conn, ids)
|
||||||
|
else:
|
||||||
kpis = {
|
kpis = {
|
||||||
"ready": len(columns["ready"]),
|
"ready": len(columns["ready"]),
|
||||||
"at_stations": len(at_station),
|
"at_stations": len(at_station),
|
||||||
@@ -297,7 +314,32 @@ def board(
|
|||||||
"elapsed_ms": sum(int(c["clock"]["elapsed_ms"]) for c in at_station),
|
"elapsed_ms": sum(int(c["clock"]["elapsed_ms"]) for c in at_station),
|
||||||
"target_hrs": round(sum(float(c["target_hrs"]) for c in at_station), 3),
|
"target_hrs": round(sum(float(c["target_hrs"]) for c in at_station), 3),
|
||||||
}
|
}
|
||||||
return {"depts": depts, "kpis": kpis, "cos": cos, "columns": columns}
|
return {
|
||||||
|
"depts": depts,
|
||||||
|
"kpis": kpis,
|
||||||
|
"cos": cos,
|
||||||
|
"columns": columns,
|
||||||
|
"facets": search.facets(conn, ids=ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _board_kpis(conn: Any, ids: list[int]) -> dict[str, Any]:
|
||||||
|
"""The supervisor KPIs over the board's whole scope, when the find bar narrows the cards."""
|
||||||
|
counts = {"ready": 0, "review": 0, "at_stations": 0}
|
||||||
|
elapsed = 0
|
||||||
|
target = 0.0
|
||||||
|
if ids:
|
||||||
|
now = conn.execute("SELECT now()").fetchone()[0]
|
||||||
|
for r in packets.rows(conn, KPI_SQL, (ids,)):
|
||||||
|
st = r["status"]
|
||||||
|
if st in ("ready", "review"):
|
||||||
|
counts[st] += 1
|
||||||
|
elif st in policy.ACTIVE:
|
||||||
|
counts["at_stations"] += 1
|
||||||
|
if r.get("clock_elapsed_ms") is not None:
|
||||||
|
elapsed += int(packets.clock_of({**r, "closed_at": None}, now)["elapsed_ms"])
|
||||||
|
target += float(r.get("target_hrs") or 0)
|
||||||
|
return {**counts, "elapsed_ms": elapsed, "target_hrs": round(target, 3)}
|
||||||
|
|
||||||
|
|
||||||
def can_verify(pp: policy.Principal, dept: str) -> bool:
|
def can_verify(pp: policy.Principal, dept: str) -> bool:
|
||||||
@@ -333,8 +375,9 @@ def stations_for(conn: Any, pp: policy.Principal) -> list[dict[str, Any]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def stall(conn: Any, pp: policy.Principal, station_id: int) -> dict[str, Any]:
|
def stall(conn: Any, pp: policy.Principal, station_id: int, filters: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||||
"""GET /api/shop/stall: the packets at one station. 403 when the station is not in the principal's list."""
|
"""GET /api/shop/stall: the packets at one station. 403 when the station is not in the principal's list.
|
||||||
|
`filters` (the find bar's) narrow the station's cards; Mine is the station's own work."""
|
||||||
mine_list = {int(s["id"]): s for s in stations_for(conn, pp)}
|
mine_list = {int(s["id"]): s for s in stations_for(conn, pp)}
|
||||||
st = mine_list.get(int(station_id))
|
st = mine_list.get(int(station_id))
|
||||||
if st is None:
|
if st is None:
|
||||||
@@ -345,11 +388,13 @@ def stall(conn: Any, pp: policy.Principal, station_id: int) -> dict[str, Any]:
|
|||||||
" AND status IN ('assigned', 'in_progress', 'review') ORDER BY updated_at, id LIMIT %s",
|
" AND status IN ('assigned', 'in_progress', 'review') ORDER BY updated_at, id LIMIT %s",
|
||||||
(int(station_id), BOARD_LIMIT),
|
(int(station_id), BOARD_LIMIT),
|
||||||
)
|
)
|
||||||
cards = _cards(conn, pp, [int(r["id"]) for r in found])
|
ids = [int(r["id"]) for r in found]
|
||||||
|
cards = _cards(conn, pp, ids, **search.board_filters(filters or {}))
|
||||||
return {
|
return {
|
||||||
"station": {"id": st["id"], "name": st["name"], "dept_code": st["dept_code"], "dept_name": st["dept_name"]},
|
"station": {"id": st["id"], "name": st["name"], "dept_code": st["dept_code"], "dept_name": st["dept_name"]},
|
||||||
"mine": [c for c in cards if c["status"] in policy.ACTIVE],
|
"mine": [c for c in cards if c["status"] in policy.ACTIVE],
|
||||||
"submitted": [c for c in cards if c["status"] == "review"],
|
"submitted": [c for c in cards if c["status"] == "review"],
|
||||||
|
"facets": search.facets(conn, ids=ids),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
A thread holds a sync psycopg connection (app role) doing LISTEN ps_events and pushes each NOTIFY payload
|
A thread holds a sync psycopg connection (app role) doing LISTEN ps_events and pushes each NOTIFY payload
|
||||||
to the event loop with call_soon_threadsafe. It reconnects with backoff when the database drops. Presence is
|
to the event loop with call_soon_threadsafe. It reconnects with backoff when the database drops. Presence is
|
||||||
kept in process (one uvicorn worker); phase 8 completes it.
|
kept in process (one uvicorn worker): one entry per (user, screen, packet) with the display name and the role
|
||||||
|
label. A locked session drops out of presence until it is unlocked; a revoked one drops out at once. A browser
|
||||||
|
that goes away drops out when its stream ends (the stream's `finally`, at the latest on the next ping).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -15,6 +17,7 @@ import threading
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -44,9 +47,17 @@ class Subscriber:
|
|||||||
display_name: str
|
display_name: str
|
||||||
screen: str | None = None
|
screen: str | None = None
|
||||||
packet_id: int | None = None # the packet the subscriber has open, for "Also here" on the packet page
|
packet_id: int | None = None # the packet the subscriber has open, for "Also here" on the packet page
|
||||||
|
role: str = "" # the label shown beside the name: the screen open, else the person's primary role
|
||||||
|
since: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
locked: bool = False # the session is locked: left out of presence until it is unlocked
|
||||||
|
revoked: bool = False # the session was revoked: left out of presence while the stream winds down
|
||||||
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def present(self) -> bool:
|
||||||
|
return not (self.closed or self.locked or self.revoked)
|
||||||
|
|
||||||
def put(self, item: Item) -> None:
|
def put(self, item: Item) -> None:
|
||||||
"""Called on the event loop."""
|
"""Called on the event loop."""
|
||||||
try:
|
try:
|
||||||
@@ -74,6 +85,8 @@ class EventHub:
|
|||||||
display_name: str,
|
display_name: str,
|
||||||
screen: str | None = None,
|
screen: str | None = None,
|
||||||
packet_id: int | None = None,
|
packet_id: int | None = None,
|
||||||
|
role: str = "",
|
||||||
|
locked: bool = False,
|
||||||
) -> Subscriber:
|
) -> Subscriber:
|
||||||
"""Register a subscriber on the running loop and broadcast presence."""
|
"""Register a subscriber on the running loop and broadcast presence."""
|
||||||
sub = Subscriber(
|
sub = Subscriber(
|
||||||
@@ -84,6 +97,8 @@ class EventHub:
|
|||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
screen=screen,
|
screen=screen,
|
||||||
packet_id=packet_id,
|
packet_id=packet_id,
|
||||||
|
role=role,
|
||||||
|
locked=locked,
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._subs[sub.id] = sub
|
self._subs[sub.id] = sub
|
||||||
@@ -97,17 +112,37 @@ class EventHub:
|
|||||||
if removed is not None:
|
if removed is not None:
|
||||||
self._broadcast_presence()
|
self._broadcast_presence()
|
||||||
|
|
||||||
def presence(self) -> dict[str, Any]:
|
def _users(self) -> list[dict[str, Any]]:
|
||||||
|
"""One entry per (user, screen, packet) among the present subscribers, the earliest `since` kept."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
subs = list(self._subs.values())
|
subs = [s for s in self._subs.values() if s.present]
|
||||||
seen: dict[tuple[str, str | None, int | None], dict[str, Any]] = {}
|
seen: dict[tuple[str, str | None, int | None], dict[str, Any]] = {}
|
||||||
for s in subs:
|
for s in sorted(subs, key=lambda x: x.since):
|
||||||
seen.setdefault(
|
seen.setdefault(
|
||||||
(s.user_id, s.screen, s.packet_id),
|
(s.user_id, s.screen, s.packet_id),
|
||||||
{"user_id": s.user_id, "display_name": s.display_name, "screen": s.screen, "packet_id": s.packet_id},
|
{
|
||||||
|
"user_id": s.user_id,
|
||||||
|
"display_name": s.display_name,
|
||||||
|
"role": s.role,
|
||||||
|
"screen": s.screen,
|
||||||
|
"packet_id": s.packet_id,
|
||||||
|
"since": s.since.isoformat(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
users = sorted(seen.values(), key=lambda u: (u["display_name"].lower(), u["screen"] or "", u["packet_id"] or 0))
|
return sorted(seen.values(), key=lambda u: (u["display_name"].lower(), u["screen"] or "", u["packet_id"] or 0))
|
||||||
return {"users": users}
|
|
||||||
|
def presence(self) -> dict[str, Any]:
|
||||||
|
"""{users: [{user_id, display_name, role, screen, packet_id, since}]}, locked and revoked sessions left
|
||||||
|
out."""
|
||||||
|
return {"users": self._users()}
|
||||||
|
|
||||||
|
def presence_by_screen(self) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""The same users grouped by the screen they have open ("none" for a stream without a screen), for the
|
||||||
|
Admin "who's in" view."""
|
||||||
|
out: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for u in self._users():
|
||||||
|
out.setdefault(u["screen"] or "none", []).append(u)
|
||||||
|
return out
|
||||||
|
|
||||||
def _send(self, sub: Subscriber, item: Item) -> None:
|
def _send(self, sub: Subscriber, item: Item) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -134,11 +169,30 @@ class EventHub:
|
|||||||
name = str(data.pop("event", "changed"))
|
name = str(data.pop("event", "changed"))
|
||||||
with self._lock:
|
with self._lock:
|
||||||
subs = list(self._subs.values())
|
subs = list(self._subs.values())
|
||||||
|
changed = False
|
||||||
if name == "session":
|
if name == "session":
|
||||||
target = str(data.get("session_id"))
|
target = str(data.get("session_id"))
|
||||||
subs = [s for s in subs if s.session_id == target]
|
subs = [s for s in subs if s.session_id == target]
|
||||||
|
changed = self._mark(subs, str(data.get("action") or ""))
|
||||||
for s in subs:
|
for s in subs:
|
||||||
self._send(s, (name, data))
|
self._send(s, (name, data))
|
||||||
|
if changed:
|
||||||
|
self._broadcast_presence()
|
||||||
|
|
||||||
|
def _mark(self, subs: list[Subscriber], action: str) -> bool:
|
||||||
|
"""Apply a session event to that session's subscribers. True when presence changed."""
|
||||||
|
changed = False
|
||||||
|
with self._lock:
|
||||||
|
for s in subs:
|
||||||
|
before = s.present
|
||||||
|
if action == "locked":
|
||||||
|
s.locked = True
|
||||||
|
elif action == "unlocked":
|
||||||
|
s.locked = False
|
||||||
|
elif action == "revoked":
|
||||||
|
s.revoked = True
|
||||||
|
changed = changed or before != s.present
|
||||||
|
return changed
|
||||||
|
|
||||||
# ------------------------------------------------ listener thread
|
# ------------------------------------------------ listener thread
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ def create_app(
|
|||||||
jobs,
|
jobs,
|
||||||
packet_children,
|
packet_children,
|
||||||
packets,
|
packets,
|
||||||
|
pm,
|
||||||
prints,
|
prints,
|
||||||
projects,
|
projects,
|
||||||
qc,
|
qc,
|
||||||
@@ -133,6 +134,7 @@ def create_app(
|
|||||||
shop,
|
shop,
|
||||||
qc,
|
qc,
|
||||||
itp,
|
itp,
|
||||||
|
pm,
|
||||||
):
|
):
|
||||||
app.include_router(module.router)
|
app.include_router(module.router)
|
||||||
app.include_router(assets.blob_router)
|
app.include_router(assets.blob_router)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""PDF output: the data book, the job traveler, the ITP, the printed copies, their stamps and metadata.
|
"""PDF output: the data book, the job traveler, the ITP, the printed copies, the PM production status report
|
||||||
|
(pmreport), their stamps and metadata.
|
||||||
|
|
||||||
Pure builders: they read through the connection they are given and the stored asset files, and return bytes. They
|
Pure builders: they read through the connection they are given and the stored asset files, and return bytes. They
|
||||||
never write to the database and never log a print; the endpoints and the filing pipeline do that.
|
never write to the database and never log a print; the endpoints and the filing pipeline do that.
|
||||||
|
|||||||
@@ -45,10 +45,12 @@ from .style import (
|
|||||||
R,
|
R,
|
||||||
WhoRef,
|
WhoRef,
|
||||||
clean,
|
clean,
|
||||||
fmt_clock,
|
fmt_clock_hhmm,
|
||||||
|
fmt_clock_hms,
|
||||||
fmt_d,
|
fmt_d,
|
||||||
fmt_dt,
|
fmt_dt,
|
||||||
fmt_num,
|
fmt_num,
|
||||||
|
round_half_up,
|
||||||
site_tz,
|
site_tz,
|
||||||
utc_now,
|
utc_now,
|
||||||
)
|
)
|
||||||
@@ -164,7 +166,7 @@ def co_summary(co: Mapping[str, Any]) -> str:
|
|||||||
added = sum(1 for i in items if i.get("added"))
|
added = sum(1 for i in items if i.get("added"))
|
||||||
if added:
|
if added:
|
||||||
lines.append(f"{added} sheet{'s' if added > 1 else ''} added")
|
lines.append(f"{added} sheet{'s' if added > 1 else ''} added")
|
||||||
return ", ".join(lines) or str(co.get("reason") or "") or "documents changed"
|
return ", ".join(lines) or "documents changed" # the old coSummary(): the reason has its own column
|
||||||
|
|
||||||
|
|
||||||
class Book:
|
class Book:
|
||||||
@@ -268,7 +270,7 @@ class Book:
|
|||||||
w, h = float(page.mediabox.width), float(page.mediabox.height)
|
w, h = float(page.mediabox.width), float(page.mediabox.height)
|
||||||
if int(page.get("/Rotate", 0) or 0) % 180 == 90:
|
if int(page.get("/Rotate", 0) or 0) % 180 == 90:
|
||||||
w, h = h, w
|
w, h = h, w
|
||||||
return f'{round(w / 72)}" x {round(h / 72)}"'
|
return f'{round_half_up(w / 72)}" x {round_half_up(h / 72)}"'
|
||||||
except Exception as e: # an unreadable sheet is reported in section 1; try the next one
|
except Exception as e: # an unreadable sheet is reported in section 1; try the next one
|
||||||
log.debug("sheet size not read: %s", type(e).__name__)
|
log.debug("sheet size not read: %s", type(e).__name__)
|
||||||
continue
|
continue
|
||||||
@@ -428,6 +430,10 @@ class Book:
|
|||||||
recs = list(closed)
|
recs = list(closed)
|
||||||
if p.get("stage"):
|
if p.get("stage"):
|
||||||
recs.append(dict(p["stage"], current=True))
|
recs.append(dict(p["stage"], current=True))
|
||||||
|
elif recs:
|
||||||
|
# Q.C. accept closes the stage here; the old build kept it as the packet's live record, which shows
|
||||||
|
# the work order target beside the time. The last closed stage plays that part.
|
||||||
|
recs[-1]["last"] = True
|
||||||
if not recs:
|
if not recs:
|
||||||
recs.append(
|
recs.append(
|
||||||
{
|
{
|
||||||
@@ -483,8 +489,8 @@ class Book:
|
|||||||
stamp = welder.get("stamp") if isinstance(welder, Mapping) else None
|
stamp = welder.get("stamp") if isinstance(welder, Mapping) else None
|
||||||
wname = parts.name_of(self.users, welder) if welder else ""
|
wname = parts.name_of(self.users, welder) if welder else ""
|
||||||
clock = rec.get("clock") or {}
|
clock = rec.get("clock") or {}
|
||||||
time = fmt_clock(clock.get("elapsed_ms"))
|
time = fmt_clock_hms(clock.get("elapsed_ms"))
|
||||||
if rec.get("current"):
|
if rec.get("current") or rec.get("last"):
|
||||||
time += f" (target {fmt_num(p.get('target_hrs') or 0)} h)"
|
time += f" (target {fmt_num(p.get('target_hrs') or 0)} h)"
|
||||||
rows: list[tuple[str, Any]] = [
|
rows: list[tuple[str, Any]] = [
|
||||||
("Department", name),
|
("Department", name),
|
||||||
@@ -869,7 +875,7 @@ class Book:
|
|||||||
(str(len(welds)), "Welds mapped"),
|
(str(len(welds)), "Welds mapped"),
|
||||||
(f"{traced}/{len(bom)}", "BOM heats traced"),
|
(f"{traced}/{len(bom)}", "BOM heats traced"),
|
||||||
(str(q.get("result") or "open").upper(), "Q.C. result"),
|
(str(q.get("result") or "open").upper(), "Q.C. result"),
|
||||||
(fmt_clock(elapsed), "Weld time (h:mm)"),
|
(fmt_clock_hhmm(elapsed), "Weld time (h:mm)"),
|
||||||
]
|
]
|
||||||
kw = (CW - 3 * 16) / 4
|
kw = (CW - 3 * 16) / 4
|
||||||
for i, (n, label) in enumerate(figures):
|
for i, (n, label) in enumerate(figures):
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ SPACE_LIMIT = 740.0
|
|||||||
LINE_FACTOR = 1.15
|
LINE_FACTOR = 1.15
|
||||||
PAD_V = 4.2
|
PAD_V = 4.2
|
||||||
PAD_H = 4.0
|
PAD_H = 4.0
|
||||||
|
# The old tables wrapped cells with jsPDF's splitTextToSize. autotable gives it the cell width less the padding plus
|
||||||
|
# 1 pt, and jsPDF's Helvetica widths run about 0.8 % narrower than the AFM widths reportlab measures with. Both are
|
||||||
|
# kept here so a cell breaks where the old book broke it ("10/18/2026 11:32 AM" stays on one line in 86 pt).
|
||||||
|
WRAP_SLACK = 1.0
|
||||||
|
JSPDF_WIDTH = 0.992
|
||||||
|
|
||||||
|
|
||||||
def rgb(c: Sequence[int]) -> Color:
|
def rgb(c: Sequence[int]) -> Color:
|
||||||
@@ -348,7 +353,8 @@ class Doc:
|
|||||||
) -> tuple[list[str], str, float]:
|
) -> tuple[list[str], str, float]:
|
||||||
font = self._font(cell, spec)
|
font = self._font(cell, spec)
|
||||||
sz = float(cell.get("size", spec.get("size", size)))
|
sz = float(cell.get("size", spec.get("size", size)))
|
||||||
return split_lines(cell.get("content"), font, sz, width - 2 * PAD_H), font, sz
|
room = (width - 2 * PAD_H + WRAP_SLACK) / JSPDF_WIDTH
|
||||||
|
return split_lines(cell.get("content"), font, sz, room), font, sz
|
||||||
|
|
||||||
def _row_height(
|
def _row_height(
|
||||||
self, row: Sequence[Any], widths: Sequence[float], cols: Mapping[int, Mapping[str, Any]], size: float
|
self, row: Sequence[Any], widths: Sequence[float], cols: Mapping[int, Mapping[str, Any]], size: float
|
||||||
@@ -372,9 +378,7 @@ class Doc:
|
|||||||
) -> None:
|
) -> None:
|
||||||
c = self.c
|
c = self.c
|
||||||
for col, span, cell in self._cells(row):
|
for col, span, cell in self._cells(row):
|
||||||
spec = {} if head else cols.get(col, {})
|
spec = {} if head else cols.get(col, {}) # autotable column styles style the body only: heads sit left
|
||||||
if head and col in cols and cols[col].get("align"):
|
|
||||||
spec = {"align": cols[col]["align"]}
|
|
||||||
x = L + sum(widths[:col])
|
x = L + sum(widths[:col])
|
||||||
w = sum(widths[col : col + span])
|
w = sum(widths[col : col + span])
|
||||||
if cell.get("fill") is not None:
|
if cell.get("fill") is not None:
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
"""The PESCO production status report: the old pmPdf, report format C (dark).
|
||||||
|
|
||||||
|
Letter, portrait: a cover with the headline numbers, PART 01 SUMMARY (the plain-words lines and the pipeline bars),
|
||||||
|
PART 02 PROJECTS (every unit), PART 03 FLOW (the aging tables), PART 04 LABOR (the hours tables) and PART 05 ISSUES
|
||||||
|
(the quality tables). Every page carries the PESCO foot, CONFIDENTIAL & PROPRIETARY and its number, and the
|
||||||
|
controlled-copy stamp. Everything comes from domain/pm.py; nothing here reads VISUAL or writes the database.
|
||||||
|
|
||||||
|
Drawn on a reportlab Canvas directly. Every y here is measured from the top of the page, as jsPDF did. Text always
|
||||||
|
goes through `clean`, and cells are wrapped with simpleSplit on plain strings, so no markup is interpreted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from reportlab.lib.colors import Color
|
||||||
|
from reportlab.lib.utils import ImageReader, simpleSplit
|
||||||
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||||
|
from reportlab.pdfgen.canvas import Canvas
|
||||||
|
|
||||||
|
from ..domain import pm
|
||||||
|
from . import stamps
|
||||||
|
from .style import WhoRef, clean, who_text
|
||||||
|
|
||||||
|
W, H, M = 612.0, 792.0, 40.0
|
||||||
|
CW = W - 2 * M
|
||||||
|
LOGO_PATH = Path(__file__).parent / "assets" / "logo_color.png"
|
||||||
|
|
||||||
|
# The old pmPdf colour map.
|
||||||
|
C: dict[str, tuple[int, int, int]] = {
|
||||||
|
"bg": (13, 22, 27),
|
||||||
|
"panel": (26, 37, 43),
|
||||||
|
"alt": (22, 41, 49),
|
||||||
|
"cyan": (73, 192, 222),
|
||||||
|
"pink": (229, 88, 159),
|
||||||
|
"amber": (231, 179, 60),
|
||||||
|
"white": (255, 255, 255),
|
||||||
|
"near": (239, 245, 248),
|
||||||
|
"body": (169, 186, 195),
|
||||||
|
"muted": (119, 135, 143),
|
||||||
|
"sub": (191, 228, 240),
|
||||||
|
"line": (38, 54, 62),
|
||||||
|
"zebra": (17, 29, 35),
|
||||||
|
}
|
||||||
|
|
||||||
|
TABLE_TOP = 70.0
|
||||||
|
TABLE_BOTTOM = 40.0
|
||||||
|
BODY_SIZE = 7.2
|
||||||
|
HEAD_SIZE = 6.8
|
||||||
|
PAD = 3.2
|
||||||
|
LINE_FACTOR = 1.15
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Built:
|
||||||
|
pdf: bytes
|
||||||
|
pages: int
|
||||||
|
stamp: str
|
||||||
|
stamp_sig: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def rgb(c: Sequence[int]) -> Color:
|
||||||
|
return Color(c[0] / 255, c[1] / 255, c[2] / 255)
|
||||||
|
|
||||||
|
|
||||||
|
def font_of(bold: bool = False) -> str:
|
||||||
|
return "Helvetica-Bold" if bold else "Helvetica"
|
||||||
|
|
||||||
|
|
||||||
|
def split_lines(text: Any, font: str, size: float, width: float) -> list[str]:
|
||||||
|
"""The text wrapped to `width`, keeping its own line breaks. An empty text is one empty line."""
|
||||||
|
out: list[str] = []
|
||||||
|
for part in clean(text).split("\n"):
|
||||||
|
out += simpleSplit(part, font, size, max(width, 1.0)) or [""]
|
||||||
|
return out or [""]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _logo() -> ImageReader | None:
|
||||||
|
try:
|
||||||
|
return ImageReader(io.BytesIO(LOGO_PATH.read_bytes()))
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Pages:
|
||||||
|
"""The report canvas. Pages are numbered as they start; each gets the dark background and the foot."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.buf = io.BytesIO()
|
||||||
|
self.c = Canvas(self.buf, pagesize=(W, H), pageCompression=1)
|
||||||
|
self.page = 1
|
||||||
|
self.bg()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ primitives
|
||||||
|
|
||||||
|
def Y(self, y: float) -> float:
|
||||||
|
return H - y
|
||||||
|
|
||||||
|
def fill(self, color: Sequence[int]) -> None:
|
||||||
|
self.c.setFillColor(rgb(color))
|
||||||
|
|
||||||
|
def rect(self, x: float, y: float, w: float, h: float, color: Sequence[int], *, radius: float = 0) -> None:
|
||||||
|
self.fill(color)
|
||||||
|
if radius:
|
||||||
|
self.c.roundRect(x, self.Y(y + h), w, h, radius, stroke=0, fill=1)
|
||||||
|
else:
|
||||||
|
self.c.rect(x, self.Y(y + h), w, h, stroke=0, fill=1)
|
||||||
|
|
||||||
|
def text(
|
||||||
|
self,
|
||||||
|
s: Any,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
*,
|
||||||
|
size: float = 9.5,
|
||||||
|
bold: bool = False,
|
||||||
|
color: Sequence[int] = C["body"],
|
||||||
|
align: str = "left",
|
||||||
|
max_width: float | None = None,
|
||||||
|
cs: float = 0.0,
|
||||||
|
) -> int:
|
||||||
|
"""jsPDF's text(): `y` is the first baseline; with max_width the text wraps. Returns the line count."""
|
||||||
|
font = font_of(bold)
|
||||||
|
lines = split_lines(s, font, size, max_width) if max_width else clean(s).split("\n")
|
||||||
|
self.fill(color)
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
yy = self.Y(y + i * size * LINE_FACTOR)
|
||||||
|
width = stringWidth(ln, font, size) + cs * len(ln)
|
||||||
|
xx = x - width if align == "right" else x - width / 2 if align == "center" else x
|
||||||
|
t = self.c.beginText(xx, yy)
|
||||||
|
t.setFont(font, size)
|
||||||
|
t.setCharSpace(cs)
|
||||||
|
t.textOut(ln)
|
||||||
|
self.c.drawText(t)
|
||||||
|
return len(lines)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ pages
|
||||||
|
|
||||||
|
def bg(self) -> None:
|
||||||
|
self.rect(0, 0, W, H, C["bg"])
|
||||||
|
|
||||||
|
def foot(self) -> None:
|
||||||
|
self.text("PESCO · PROCESS EQUIPMENT & SERVICE COMPANY, INC.", M, H - 22, size=7, color=C["muted"])
|
||||||
|
self.text("CONFIDENTIAL & PROPRIETARY", W / 2, H - 22, size=7, color=C["amber"], align="center", bold=True)
|
||||||
|
self.text(str(self.page), W - M, H - 22, size=7, color=C["muted"], align="right")
|
||||||
|
|
||||||
|
def new_page(self) -> None:
|
||||||
|
self.c.showPage()
|
||||||
|
self.page += 1
|
||||||
|
self.bg()
|
||||||
|
self.foot()
|
||||||
|
|
||||||
|
def body(self, eyebrow: str, title: str, stand: str) -> float:
|
||||||
|
"""A body page: the eyebrow, "Production status report", a rule, the title and the stand-first."""
|
||||||
|
self.new_page()
|
||||||
|
self.text(eyebrow, M, 44, size=7.6, color=C["cyan"], bold=True, cs=1.2)
|
||||||
|
self.text("Production status report", W - M, 44, size=7.6, color=C["muted"], align="right")
|
||||||
|
self.c.setStrokeColor(rgb(C["line"]))
|
||||||
|
self.c.setLineWidth(0.6)
|
||||||
|
self.c.line(M, self.Y(52), W - M, self.Y(52))
|
||||||
|
self.text(title, M, 82, size=21, bold=True, color=C["near"])
|
||||||
|
if stand:
|
||||||
|
self.text(stand, M, 100, size=9.5, color=C["body"], max_width=CW)
|
||||||
|
return 118.0 if stand else 100.0
|
||||||
|
|
||||||
|
def finish(self) -> bytes:
|
||||||
|
self.c.showPage()
|
||||||
|
self.c.save()
|
||||||
|
return self.buf.getvalue()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ tables
|
||||||
|
|
||||||
|
def _widths(self, head: Sequence[str], body: Sequence[Sequence[str]]) -> list[float]:
|
||||||
|
n = len(head)
|
||||||
|
natural = [2 * PAD + 10.0] * n
|
||||||
|
minw = [2 * PAD + 10.0] * n
|
||||||
|
rows: list[tuple[Sequence[str], str, float]] = [(head, font_of(True), HEAD_SIZE)]
|
||||||
|
rows += [(r, font_of(), BODY_SIZE) for r in body]
|
||||||
|
for cells, font, size in rows:
|
||||||
|
for i in range(min(n, len(cells))):
|
||||||
|
for part in clean(cells[i]).split("\n"):
|
||||||
|
natural[i] = max(natural[i], stringWidth(part, font, size) + 2 * PAD)
|
||||||
|
for word in part.split():
|
||||||
|
minw[i] = max(minw[i], min(stringWidth(word, font, size) + 2 * PAD, CW / 3))
|
||||||
|
total = sum(natural)
|
||||||
|
if total <= CW:
|
||||||
|
return [w * CW / total for w in natural]
|
||||||
|
base = sum(minw)
|
||||||
|
if base >= CW:
|
||||||
|
return [w * CW / base for w in minw]
|
||||||
|
extra = [natural[i] - minw[i] for i in range(n)]
|
||||||
|
spare = sum(extra) or 1.0
|
||||||
|
return [minw[i] + (CW - base) * extra[i] / spare for i in range(n)]
|
||||||
|
|
||||||
|
def _row_height(self, cells: Sequence[str], widths: Sequence[float], font: str, size: float) -> float:
|
||||||
|
lines = max(
|
||||||
|
len(split_lines(cells[i] if i < len(cells) else "", font, size, widths[i] - 2 * PAD))
|
||||||
|
for i in range(len(widths))
|
||||||
|
)
|
||||||
|
return lines * size * LINE_FACTOR + 2 * PAD
|
||||||
|
|
||||||
|
def _draw_row(
|
||||||
|
self,
|
||||||
|
cells: Sequence[str],
|
||||||
|
widths: Sequence[float],
|
||||||
|
y: float,
|
||||||
|
h: float,
|
||||||
|
*,
|
||||||
|
font: str,
|
||||||
|
size: float,
|
||||||
|
color: Sequence[int],
|
||||||
|
fill: Sequence[int],
|
||||||
|
) -> None:
|
||||||
|
x = M
|
||||||
|
self.c.setStrokeColor(rgb(C["line"]))
|
||||||
|
self.c.setLineWidth(0.4)
|
||||||
|
for i, w in enumerate(widths):
|
||||||
|
self.fill(fill)
|
||||||
|
self.c.rect(x, self.Y(y + h), w, h, stroke=1, fill=1)
|
||||||
|
lines = split_lines(cells[i] if i < len(cells) else "", font, size, w - 2 * PAD)
|
||||||
|
self.fill(color)
|
||||||
|
self.c.setFont(font, size)
|
||||||
|
for j, ln in enumerate(lines):
|
||||||
|
self.c.drawString(x + PAD, self.Y(y + PAD + j * size * LINE_FACTOR + size * 0.85), ln)
|
||||||
|
x += w
|
||||||
|
|
||||||
|
def table(self, t: Mapping[str, Any], start_y: float) -> float:
|
||||||
|
"""The old autoTable look: the head in cyan on the alt fill, zebra rows, flagged rows in pink, project rows
|
||||||
|
bold. The head repeats on every page. Returns the y under the table."""
|
||||||
|
head = [str(h) for h in t["head"]]
|
||||||
|
rows = list(t["rows"]) or [{"cells": ["Nothing in scope.", *[""] * (len(head) - 1)]}]
|
||||||
|
body = [[str(c) for c in r["cells"]] for r in rows]
|
||||||
|
widths = self._widths(head, body)
|
||||||
|
limit = H - TABLE_BOTTOM
|
||||||
|
is_projects = t.get("key") == "projects"
|
||||||
|
head_h = self._row_height(head, widths, font_of(True), HEAD_SIZE)
|
||||||
|
|
||||||
|
def draw_head(yy: float) -> float:
|
||||||
|
self._draw_row(head, widths, yy, head_h, font=font_of(True), size=HEAD_SIZE, color=C["cyan"], fill=C["alt"])
|
||||||
|
return yy + head_h
|
||||||
|
|
||||||
|
y = start_y
|
||||||
|
first = self._row_height(body[0], widths, font_of(), BODY_SIZE)
|
||||||
|
if y + head_h + first > limit:
|
||||||
|
self.new_page()
|
||||||
|
y = TABLE_TOP
|
||||||
|
y = draw_head(y)
|
||||||
|
for i, (r, cells) in enumerate(zip(rows, body, strict=True)):
|
||||||
|
bold = is_projects and r.get("lvl") == 0 and r.get("project_id") is not None
|
||||||
|
font = font_of(bold)
|
||||||
|
h = self._row_height(cells, widths, font, BODY_SIZE)
|
||||||
|
if y + h > limit:
|
||||||
|
self.new_page()
|
||||||
|
y = draw_head(TABLE_TOP)
|
||||||
|
color = C["pink"] if r.get("flag") else C["near"] if bold else C["body"]
|
||||||
|
fill = C["zebra"] if i % 2 == 1 else C["bg"]
|
||||||
|
self._draw_row(cells, widths, y, h, font=font, size=BODY_SIZE, color=color, fill=fill)
|
||||||
|
y += h
|
||||||
|
return y
|
||||||
|
|
||||||
|
def section(self, eyebrow: str, title: str, stand: str, tables: Sequence[Mapping[str, Any]]) -> None:
|
||||||
|
y = self.body(eyebrow, title, stand)
|
||||||
|
for t in tables:
|
||||||
|
if y > H - 140:
|
||||||
|
y = self.body(eyebrow, title + " (continued)", "")
|
||||||
|
self.text(t["title"], M, y + 6, size=11, bold=True, color=C["near"])
|
||||||
|
if t.get("note"):
|
||||||
|
self.text(t["note"], M, y + 20, size=7.8, color=C["muted"], max_width=CW)
|
||||||
|
y = self.table(t, y + (30 if t.get("note") else 16)) + 22
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the pages
|
||||||
|
|
||||||
|
|
||||||
|
def _cover(r: Pages, rep: pm.Report, kpis: list[dict[str, Any]], who: WhoRef | None) -> None:
|
||||||
|
for i in range(60):
|
||||||
|
t = i / 60
|
||||||
|
r.rect(0, i * 4.4, W, 4.6, (round(15 + 10 * (1 - t)), round(48 + 20 * (1 - t)), round(58 + 22 * (1 - t))))
|
||||||
|
logo = _logo()
|
||||||
|
if logo is not None:
|
||||||
|
# A logo that does not draw is left out, as the old build did.
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
r.c.drawImage(logo, M, r.Y(46 + 34), width=150, height=34, mask="auto", preserveAspectRatio=True)
|
||||||
|
r.text("PRODUCTION STATUS REPORT", M, 150, size=30, bold=True, color=C["white"])
|
||||||
|
r.text(f"Packet Station · {rep.scope_label()}", M, 176, size=12, color=C["sub"], max_width=CW)
|
||||||
|
for i in range(100):
|
||||||
|
t = i / 100
|
||||||
|
col = (round(73 + (229 - 73) * t), round(192 + (88 - 192) * t), round(222 + (159 - 222) * t))
|
||||||
|
r.rect(M + i * CW / 100, 196, CW / 100 + 0.5, 3, col)
|
||||||
|
cw = (CW - 3 * 10) / 4
|
||||||
|
for i, k in enumerate(kpis[:8]):
|
||||||
|
cx = M + (i % 4) * (cw + 10)
|
||||||
|
cy = 300 + (i // 4) * 96
|
||||||
|
hot = bool(k["alert"]) and str(k["n"]) != "0"
|
||||||
|
r.rect(cx, cy, cw, 84, C["panel"], radius=6)
|
||||||
|
r.rect(cx, cy, cw, 3, C["pink"] if hot else C["cyan"])
|
||||||
|
r.text(str(k["n"]), cx + 12, cy + 44, size=30, bold=True, color=C["white"])
|
||||||
|
r.text(
|
||||||
|
str(k["label"]).upper(),
|
||||||
|
cx + 12,
|
||||||
|
cy + 68,
|
||||||
|
size=7.2,
|
||||||
|
bold=True,
|
||||||
|
color=C["pink"] if hot else C["cyan"],
|
||||||
|
max_width=cw - 20,
|
||||||
|
)
|
||||||
|
meta = [
|
||||||
|
("Prepared by", str(who["display_name"]) if who and who.get("display_name") else who_text(who)),
|
||||||
|
("Source", "Packet Station live data"),
|
||||||
|
("History period", rep.period_label),
|
||||||
|
("Issued", pm.fmt_dt(rep.now, rep.tz)),
|
||||||
|
]
|
||||||
|
for i, (a, b) in enumerate(meta):
|
||||||
|
x = M + i * (CW / 4)
|
||||||
|
r.text(a.upper(), x, 560, size=7, color=C["muted"], bold=True, cs=0.6)
|
||||||
|
r.text(b, x, 575, size=9.5, color=C["near"], max_width=CW / 4 - 8)
|
||||||
|
r.text(
|
||||||
|
"Live snapshot of the shop's job packets at the time of issue. Figures follow the scope shown above.",
|
||||||
|
M,
|
||||||
|
610,
|
||||||
|
size=8.5,
|
||||||
|
color=C["muted"],
|
||||||
|
max_width=CW,
|
||||||
|
)
|
||||||
|
r.foot()
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(r: Pages, lines: list[str], pipe: list[dict[str, Any]]) -> None:
|
||||||
|
y = r.body("PART 01 · SUMMARY", "Where things stand", "The short version first; the detail follows.")
|
||||||
|
for ln in lines:
|
||||||
|
r.fill(C["cyan"])
|
||||||
|
r.c.circle(M + 4, r.Y(y - 3), 2, stroke=0, fill=1)
|
||||||
|
n = r.text(ln, M + 14, y, size=10, color=C["near"], max_width=CW - 14)
|
||||||
|
y += n * 13 + 6
|
||||||
|
y += 10
|
||||||
|
top = max(1, *(s["count"] for s in pipe))
|
||||||
|
slow = next((s for s in pipe if s["slowest"]), None)
|
||||||
|
r.rect(M, y, CW, 34 + len(pipe) * 22, C["alt"], radius=6)
|
||||||
|
r.text("Open packets by step", M + 14, y + 20, size=10.5, bold=True, color=C["white"])
|
||||||
|
r.text(
|
||||||
|
"bar = packets in the step · label = average time there",
|
||||||
|
W - M - 14,
|
||||||
|
y + 20,
|
||||||
|
size=7.5,
|
||||||
|
color=C["muted"],
|
||||||
|
align="right",
|
||||||
|
)
|
||||||
|
y += 36
|
||||||
|
track = CW - 190 - 110
|
||||||
|
for s in pipe:
|
||||||
|
is_slow = s is slow
|
||||||
|
r.text(s["label"], M + 14, y + 9, size=8.2, color=C["body"])
|
||||||
|
bx = M + 190
|
||||||
|
r.rect(bx, y + 1, track, 10, C["line"])
|
||||||
|
if s["count"]:
|
||||||
|
r.rect(bx, y + 1, max(2.0, track * s["count"] / top), 10, C["pink"] if is_slow else C["cyan"])
|
||||||
|
label = f"{s['count']}" + (f" · avg {pm.fmt_days(s['avg_s'])}" if s["count"] else "")
|
||||||
|
label += " · slowest" if is_slow else ""
|
||||||
|
r.text(label, W - M - 104, y + 9, size=8, color=C["pink"] if is_slow else C["near"], bold=is_slow)
|
||||||
|
y += 22
|
||||||
|
y += 12
|
||||||
|
r.rect(M, y, CW, 40, C["panel"])
|
||||||
|
r.rect(M, y, 3, 40, C["cyan"])
|
||||||
|
r.text("What this says:", M + 12, y + 16, size=9.5, bold=True, color=C["white"])
|
||||||
|
says = (
|
||||||
|
f'"{slow["label"]}" is where packets wait longest right now'
|
||||||
|
f" (average {pm.fmt_days(slow['avg_s'])}). Start there."
|
||||||
|
if slow
|
||||||
|
else "Nothing is waiting in any step."
|
||||||
|
)
|
||||||
|
r.text(says, M + 12, y + 30, size=9, color=C["body"], max_width=CW - 24)
|
||||||
|
|
||||||
|
|
||||||
|
def build(
|
||||||
|
conn: Any,
|
||||||
|
settings: Any,
|
||||||
|
*,
|
||||||
|
scope_params: Mapping[str, Any],
|
||||||
|
who: WhoRef | None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
report: pm.Report | None = None,
|
||||||
|
) -> Built:
|
||||||
|
"""The report as PDF bytes, its page count and the stamp. `scope_params` are the find bar values plus
|
||||||
|
project_id, unit_id and period; a `report` already read for the same scope is used as it is."""
|
||||||
|
now = now or datetime.now(UTC)
|
||||||
|
rep = report
|
||||||
|
if rep is None:
|
||||||
|
rep = pm.load(
|
||||||
|
conn,
|
||||||
|
None,
|
||||||
|
scope_params,
|
||||||
|
now=now,
|
||||||
|
tz=str(getattr(settings, "site_tz", None) or "America/Denver"),
|
||||||
|
period=scope_params.get("period"),
|
||||||
|
)
|
||||||
|
pipe = pm.pipeline(rep)
|
||||||
|
kpis = pm.kpis(rep)
|
||||||
|
lines = pm.summary(rep, kpis, pipe)
|
||||||
|
|
||||||
|
r = Pages()
|
||||||
|
_cover(r, rep, kpis, who)
|
||||||
|
_summary(r, lines, pipe)
|
||||||
|
r.section(
|
||||||
|
"PART 02 · PROJECTS",
|
||||||
|
"Projects and units",
|
||||||
|
"Every unit's packets by step, how much is done, and whether it's on track.",
|
||||||
|
[pm.projects_table(rep, expand_all=True)],
|
||||||
|
)
|
||||||
|
r.section(
|
||||||
|
"PART 03 · FLOW",
|
||||||
|
"Bottlenecks and aging",
|
||||||
|
"How long packets sit in each step, oldest first.",
|
||||||
|
pm.aging_tables(rep, pipe),
|
||||||
|
)
|
||||||
|
r.section(
|
||||||
|
"PART 04 · LABOR",
|
||||||
|
"Hours vs target",
|
||||||
|
"Station clock hours and VISUAL ticket hours against work-order target hours.",
|
||||||
|
pm.hours_tables(rep),
|
||||||
|
)
|
||||||
|
r.section(
|
||||||
|
"PART 05 · ISSUES",
|
||||||
|
"Quality and issues",
|
||||||
|
"Q.C. rejects, shorts, change orders, holds and send-backs.",
|
||||||
|
pm.quality_tables(rep),
|
||||||
|
)
|
||||||
|
raw = r.finish()
|
||||||
|
pages = r.page
|
||||||
|
|
||||||
|
text = stamps.controlled_stamp(who, now, mode="report")
|
||||||
|
line, sig = stamps.signed_stamp(settings, text, packet_id=None, kind="report", seal_hash_hex=None, who=who, now=now)
|
||||||
|
out = stamps.apply_stamp(raw, line)
|
||||||
|
out = stamps.set_metadata(
|
||||||
|
out, "Production status report", rep.scope_label(), "Packet Station report", "Packet Station", now=now
|
||||||
|
)
|
||||||
|
return Built(pdf=out, pages=pages, stamp=line, stamp_sig=sig or None)
|
||||||
@@ -35,7 +35,10 @@ Draw = Callable[[Canvas, int, float, float], None]
|
|||||||
|
|
||||||
def controlled_stamp(who: WhoRef | None, now: datetime | None, *, mode: str, seal_hash_hex: str | None = None) -> str:
|
def controlled_stamp(who: WhoRef | None, now: datetime | None, *, mode: str, seal_hash_hex: str | None = None) -> str:
|
||||||
"""The controlled-copy line. mode "filed" names the seal (first 12 hex), "preview" says not yet filed, "print"
|
"""The controlled-copy line. mode "filed" names the seal (first 12 hex), "preview" says not yet filed, "print"
|
||||||
says printed by (and the seal or not yet filed), "traveler" carries no seal part."""
|
says printed by (and the seal or not yet filed), "traveler" carries no seal part, "report" (the PM production
|
||||||
|
status report) names Packet Station and carries no seal part."""
|
||||||
|
if mode == "report":
|
||||||
|
return ascii_only(f"Controlled copy - Packet Station - printed by {who_text(who)} {utc_stamp(now)}")
|
||||||
verb = "printed" if mode == "print" else "exported"
|
verb = "printed" if mode == "print" else "exported"
|
||||||
text = f"Controlled copy - {verb} by {who_text(who)} {utc_stamp(now)}"
|
text = f"Controlled copy - {verb} by {who_text(who)} {utc_stamp(now)}"
|
||||||
if mode != "traveler":
|
if mode != "traveler":
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ The colours, the page frame and the address lines are the old databook.js values
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
@@ -148,6 +149,31 @@ def fmt_clock(ms: Any) -> str:
|
|||||||
return f"{t // 3600}:{t % 3600 // 60:02d}"
|
return f"{t // 3600}:{t % 3600 // 60:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _hms(ms: Any) -> tuple[int, int, int]:
|
||||||
|
try:
|
||||||
|
t = max(0, int(ms or 0) // 1000)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
t = 0
|
||||||
|
return t // 3600, t % 3600 // 60, t % 60
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_clock_hms(ms: Any) -> str:
|
||||||
|
"""Elapsed milliseconds as the old fmtClock(): HH:MM:SS, zero padded (the weld record's time recorded)."""
|
||||||
|
h, m, s = _hms(ms)
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_clock_hhmm(ms: Any) -> str:
|
||||||
|
"""Elapsed milliseconds as HH:MM, zero padded (the cover's weld time, the old fmtClock().slice(0,5))."""
|
||||||
|
h, m, _s = _hms(ms)
|
||||||
|
return f"{h:02d}:{m:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def round_half_up(x: float) -> int:
|
||||||
|
"""JavaScript's Math.round for positive numbers: 8.5 gives 9 (Python's round gives 8)."""
|
||||||
|
return math.floor(x + 0.5)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- dates
|
# ---------------------------------------------------------------- dates
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,5 +17,13 @@ export default defineConfig({
|
|||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
},
|
},
|
||||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
// Phase 8: the PM, find bar and presence scenarios again at iPad landscape size, with touch.
|
||||||
|
{
|
||||||
|
name: "ipad",
|
||||||
|
testMatch: /(pm|findbar|presence)\.spec\.ts/,
|
||||||
|
use: { ...devices["Desktop Chrome"], viewport: { width: 1024, height: 768 }, isMobile: false, hasTouch: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { signInPassword } from "./helpers";
|
||||||
|
import { actor, api, closeAll, stepApi } from "./shophelpers";
|
||||||
|
import {
|
||||||
|
L,
|
||||||
|
cardAnimations,
|
||||||
|
cardOf,
|
||||||
|
chip,
|
||||||
|
expectShowing,
|
||||||
|
filtersButton,
|
||||||
|
fltSelect,
|
||||||
|
jobList,
|
||||||
|
listIds,
|
||||||
|
lsGet,
|
||||||
|
makeDraft,
|
||||||
|
makeProject,
|
||||||
|
makeUnit,
|
||||||
|
personId,
|
||||||
|
pickProject,
|
||||||
|
pullWithHeat,
|
||||||
|
runSuffix,
|
||||||
|
searchBox,
|
||||||
|
viewButton,
|
||||||
|
type Made,
|
||||||
|
type Unit,
|
||||||
|
} from "./pmhelpers";
|
||||||
|
|
||||||
|
// Phase 8 find bar, list view, KPI taps, Back, sheets and motion on the CI stack, modelled on the old tests/ps
|
||||||
|
// tflt, tlist, tlay2, tnav, tpop and tmotion. clerk.clara makes a project with lots 052, 053 and 054 and four packets
|
||||||
|
// through the API; wh.wade pulls them (F2 with its own heat, F3 with line 3 short) and sup.wes assigns F1 to
|
||||||
|
// weld.andy at Stall 1 (started) and F2 to dup.dave at Stall 2. F4 goes to department 11. sup.wes then works the
|
||||||
|
// Supervisor home with the project picked, so the counts are this run's packets only. A second project holds 30
|
||||||
|
// bare drafts for the Back test on the Clerical list. Every UI label used here is in L (pmhelpers.ts).
|
||||||
|
|
||||||
|
let RUN = "";
|
||||||
|
let PROJECT = "";
|
||||||
|
let NAV = "";
|
||||||
|
const F: Record<"f1" | "f2" | "f3" | "f4", Made> = {} as Record<"f1" | "f2" | "f3" | "f4", Made>;
|
||||||
|
let andyId = "";
|
||||||
|
|
||||||
|
async function supervisorHome(page: Page): Promise<void> {
|
||||||
|
await page.goto(L.homes.supervisor);
|
||||||
|
await expect(searchBox(page)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(page: Page, q: string): Promise<void> {
|
||||||
|
await searchBox(page).fill(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.serial("find bar", () => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
|
||||||
|
test("clerk.clara, wh.wade and sup.wes make the find bar packets", async ({ browser }, info) => {
|
||||||
|
RUN = runSuffix(info.project.name);
|
||||||
|
PROJECT = `FB${RUN}`;
|
||||||
|
NAV = `NV${RUN}`;
|
||||||
|
const clara = await actor(browser, "clerk.clara");
|
||||||
|
const wade = await actor(browser, "wh.wade");
|
||||||
|
const wes = await actor(browser, "sup.wes");
|
||||||
|
const andy = await actor(browser, "weld.andy");
|
||||||
|
// dup.dave exists in the app only once he has signed in (the AD refresh runs later, in signin.spec.ts)
|
||||||
|
const dave = await actor(browser, "dup.dave");
|
||||||
|
const proj = await makeProject(clara, PROJECT, { name: "Find bar job", customer: "CI Gas" });
|
||||||
|
const lots: Unit[] = [];
|
||||||
|
for (const lot of ["052", "053", "054", "055"]) lots.push(await makeUnit(clara, Number(proj.id), lot));
|
||||||
|
const [u52, u53, u54, u55] = lots as [Unit, Unit, Unit, Unit];
|
||||||
|
F.f1 = await makeDraft(clara, u52, `FB one ${RUN}`, { send: "warehouse", wo: `5Z${RUN}` });
|
||||||
|
F.f2 = await makeDraft(clara, u53, `FB two ${RUN}`, { send: "warehouse", customer: `Zephyr ${RUN}` });
|
||||||
|
F.f3 = await makeDraft(clara, u54, `FB three ${RUN}`, { send: "warehouse" });
|
||||||
|
F.f4 = await makeDraft(clara, u55, `FB four ${RUN}`, { send: "11" });
|
||||||
|
await pullWithHeat(wade, F.f1.id, `HA${RUN}-`);
|
||||||
|
await pullWithHeat(wade, F.f2.id, `HX${RUN}-`);
|
||||||
|
await pullWithHeat(wade, F.f3.id, `HC${RUN}-`, [3]);
|
||||||
|
andyId = await personId(wes, "weld.andy");
|
||||||
|
const daveId = await personId(wes, "dup.dave");
|
||||||
|
const stalls = (await api(wes, "get", "/api/shop/stations")) as unknown as Array<Record<string, unknown>>;
|
||||||
|
const stall = (name: string): number =>
|
||||||
|
Number(stalls.find((s) => s.dept_code === "03" && s.name === name)?.id ?? 0);
|
||||||
|
await stepApi(wes, F.f1.id, "assign", { station_id: stall("Stall 1"), welder_id: andyId });
|
||||||
|
await stepApi(andy, F.f1.id, "start");
|
||||||
|
await stepApi(wes, F.f2.id, "assign", { station_id: stall("Stall 2"), welder_id: daveId });
|
||||||
|
// 30 bare drafts for the Back test
|
||||||
|
const nav = await makeProject(clara, NAV, { name: "Back test" });
|
||||||
|
const nu = await makeUnit(clara, Number(nav.id), "001");
|
||||||
|
for (let i = 0; i < 30; i++) await makeDraft(clara, nu, `Nav ${String(i).padStart(2, "0")} ${RUN}`);
|
||||||
|
await closeAll(clara, wade, wes, andy, dave);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tflt: search, Esc, status and station, clear, the shorts chip", async ({ browser }) => {
|
||||||
|
const wes = await actor(browser, "sup.wes");
|
||||||
|
const page = wes.page;
|
||||||
|
await page.evaluate(() => window.localStorage.removeItem("ps.flt"));
|
||||||
|
await supervisorHome(page);
|
||||||
|
await pickProject(page, PROJECT);
|
||||||
|
await expectShowing(page, 3); // F4 is in department 11
|
||||||
|
|
||||||
|
// a heat number, typed: the focus and the text stay
|
||||||
|
await searchBox(page).click();
|
||||||
|
await page.keyboard.type(`hx${RUN.toLowerCase()}`, { delay: 30 });
|
||||||
|
await expectShowing(page, 1);
|
||||||
|
await expect(cardOf(page, F.f2)).toBeVisible();
|
||||||
|
await expect(searchBox(page)).toBeFocused();
|
||||||
|
await expect(searchBox(page)).toHaveValue(`hx${RUN.toLowerCase()}`);
|
||||||
|
// Esc clears and keeps the focus
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(searchBox(page)).toHaveValue("");
|
||||||
|
await expect(searchBox(page)).toBeFocused();
|
||||||
|
await expectShowing(page, 3);
|
||||||
|
// a work order, a welder, a lot, a customer
|
||||||
|
await search(page, `5Z${RUN}`);
|
||||||
|
await expectShowing(page, 1);
|
||||||
|
await expect(cardOf(page, F.f1)).toBeVisible();
|
||||||
|
await search(page, "andy");
|
||||||
|
await expectShowing(page, 1);
|
||||||
|
await search(page, "lot 54");
|
||||||
|
await expectShowing(page, 1);
|
||||||
|
await expect(cardOf(page, F.f3)).toBeVisible();
|
||||||
|
await search(page, `zephyr ${RUN}`);
|
||||||
|
await expectShowing(page, 1);
|
||||||
|
await expect(cardOf(page, F.f2)).toBeVisible();
|
||||||
|
await search(page, "");
|
||||||
|
await expectShowing(page, 3);
|
||||||
|
|
||||||
|
// Filters: status "At a station", then the station dropdown
|
||||||
|
await filtersButton(page).click();
|
||||||
|
await expect(filtersButton(page)).toHaveAttribute("aria-expanded", "true");
|
||||||
|
await fltSelect(page, L.find.status).selectOption({ label: L.find.statusAtStation });
|
||||||
|
await expectShowing(page, 2);
|
||||||
|
await fltSelect(page, L.find.station).selectOption({ label: "Stall 2" });
|
||||||
|
await expectShowing(page, 1);
|
||||||
|
await expect(cardOf(page, F.f2)).toBeVisible();
|
||||||
|
// Clear filters
|
||||||
|
await page.getByRole("button", { name: L.find.clear }).click();
|
||||||
|
// Clear filters drops the project pick too, as the old build did; pick it again for the next step.
|
||||||
|
await pickProject(page, PROJECT);
|
||||||
|
await expectShowing(page, 3);
|
||||||
|
// the shorts chip
|
||||||
|
await chip(page, L.find.chips.short).click();
|
||||||
|
await expect(chip(page, L.find.chips.short)).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expectShowing(page, 1);
|
||||||
|
await expect(cardOf(page, F.f3)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: L.find.clear }).click();
|
||||||
|
// Clear filters drops the project pick too, as the old build did; pick it again for the next step.
|
||||||
|
await pickProject(page, PROJECT);
|
||||||
|
await expectShowing(page, 3);
|
||||||
|
await closeAll(wes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tflt and tlist: the list view, sort, Enter opens, saved filters, only the screen's packets", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const wes = await actor(browser, "sup.wes");
|
||||||
|
const page = wes.page;
|
||||||
|
await supervisorHome(page);
|
||||||
|
await pickProject(page, PROJECT);
|
||||||
|
await viewButton(page, "List").click();
|
||||||
|
await expect(viewButton(page, "List")).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(jobList(page)).toBeVisible();
|
||||||
|
await expect.poll(async () => (await listIds(page)).length).toBe(3);
|
||||||
|
// the 11 packet is in the project but not on the 03 supervisor's boards, so not in the list
|
||||||
|
expect(await listIds(page)).not.toContain(F.f4.id);
|
||||||
|
await page.getByLabel(L.find.sortBy).selectOption({ label: L.find.sortProject });
|
||||||
|
await expect.poll(() => listIds(page)).toEqual([F.f1.id, F.f2.id, F.f3.id]);
|
||||||
|
// the header and the KPIs stay in the list view
|
||||||
|
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: new RegExp(L.kpi.ready) }).first()).toBeVisible();
|
||||||
|
// saved: the view and the sort, never the search text
|
||||||
|
await search(page, "zzz-not-saved");
|
||||||
|
await expect(page.getByText(L.find.empty)).toBeVisible();
|
||||||
|
await search(page, "");
|
||||||
|
const saved = JSON.parse((await lsGet(page, "ps.flt")) ?? "{}") as Record<string, unknown>;
|
||||||
|
expect((saved.views as Record<string, string>).supervisor).toBe("list");
|
||||||
|
expect(saved.sort).toBe("project");
|
||||||
|
expect("q" in saved).toBe(false);
|
||||||
|
// Enter opens a row
|
||||||
|
const row = jobList(page).locator(`tbody tr[data-card-id="${F.f2.id}"]`);
|
||||||
|
await row.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/packets/${F.f2.id}$`));
|
||||||
|
await page.goBack();
|
||||||
|
await expect(jobList(page)).toBeVisible(); // the view came back from ps.flt
|
||||||
|
await viewButton(page, "Board").click();
|
||||||
|
await expect(cardOf(page, F.f1)).toBeVisible();
|
||||||
|
await closeAll(wes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tlay2: a KPI tap filters the board and the KPIs stay; Filters opens and closes", async ({ browser }) => {
|
||||||
|
const wes = await actor(browser, "sup.wes");
|
||||||
|
const page = wes.page;
|
||||||
|
await supervisorHome(page);
|
||||||
|
await pickProject(page, PROJECT);
|
||||||
|
await expectShowing(page, 3);
|
||||||
|
const tile = page.getByRole("button", { name: new RegExp(L.kpi.atStations) }).first();
|
||||||
|
// The tiles count up under motion, so read their labels (the final numbers) rather than the drawn text.
|
||||||
|
const kpiText = async (): Promise<string> =>
|
||||||
|
(await page.locator(".kpis").first().locator("[aria-label]").evaluateAll((els) => els.map((e) => e.getAttribute("aria-label")))).join(" | ");
|
||||||
|
// The numbers follow the project pick through their own request; wait for them before taking the snapshot.
|
||||||
|
await expect(tile).toHaveAccessibleName(/At stations: 2$/);
|
||||||
|
const before = await kpiText();
|
||||||
|
await tile.click();
|
||||||
|
await expect(tile).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expectShowing(page, 2);
|
||||||
|
expect(await kpiText()).toBe(before);
|
||||||
|
await expect(fltSelect(page, L.find.status)).toBeVisible(); // the tap opened the filters
|
||||||
|
await tile.click();
|
||||||
|
await expect(tile).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await expectShowing(page, 3);
|
||||||
|
// Filters opens and closes
|
||||||
|
const btn = filtersButton(page);
|
||||||
|
if ((await btn.getAttribute("aria-expanded")) === "true") await btn.click();
|
||||||
|
await expect(fltSelect(page, L.find.dept)).toHaveCount(0);
|
||||||
|
await btn.click();
|
||||||
|
await expect(fltSelect(page, L.find.dept)).toBeVisible();
|
||||||
|
await btn.click();
|
||||||
|
await expect(fltSelect(page, L.find.dept)).toHaveCount(0);
|
||||||
|
await closeAll(wes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tnav: Back from a packet lands on the home at the same scroll spot", async ({ browser }) => {
|
||||||
|
const clara = await actor(browser, "clerk.clara");
|
||||||
|
const page = clara.page;
|
||||||
|
await page.setViewportSize({ width: 1280, height: 600 });
|
||||||
|
await page.goto(L.homes.clerical);
|
||||||
|
await expect(searchBox(page)).toBeVisible();
|
||||||
|
await pickProject(page, NAV);
|
||||||
|
await viewButton(page, "List").click();
|
||||||
|
await expect.poll(async () => (await listIds(page)).length).toBeGreaterThanOrEqual(30);
|
||||||
|
const target = await page.evaluate(() => Math.min(900, document.documentElement.scrollHeight - window.innerHeight));
|
||||||
|
expect(target).toBeGreaterThan(300);
|
||||||
|
await page.evaluate((y) => window.scrollTo(0, y), target);
|
||||||
|
await expect.poll(() => page.evaluate(() => Math.round(window.scrollY))).toBe(target);
|
||||||
|
// a row fully on screen
|
||||||
|
const id = await page.evaluate(() => {
|
||||||
|
const rows = [...document.querySelectorAll("table.joblist tbody tr[data-card-id]")];
|
||||||
|
const r = rows.find((e) => {
|
||||||
|
const b = e.getBoundingClientRect();
|
||||||
|
return b.top > 120 && b.bottom < window.innerHeight - 20;
|
||||||
|
});
|
||||||
|
return r ? Number(r.getAttribute("data-card-id")) : 0;
|
||||||
|
});
|
||||||
|
expect(id).toBeGreaterThan(0);
|
||||||
|
await page.locator(`table.joblist tbody tr[data-card-id="${id}"]`).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/packets/${id}$`));
|
||||||
|
await page.getByRole("button", { name: L.back }).or(page.getByRole("link", { name: L.back })).first().click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${L.homes.clerical}$`));
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => Math.round(window.scrollY)), { timeout: 5_000 })
|
||||||
|
.toBeGreaterThan(target - 40);
|
||||||
|
expect(await page.evaluate(() => Math.round(window.scrollY))).toBeLessThan(target + 40);
|
||||||
|
await closeAll(clara);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tpop: the New packet sheet keeps its margins on desktop and phone widths", async ({ browser }) => {
|
||||||
|
for (const vp of [
|
||||||
|
{ width: 1280, height: 800 },
|
||||||
|
{ width: 390, height: 740 },
|
||||||
|
]) {
|
||||||
|
const clara = await actor(browser, "clerk.clara");
|
||||||
|
const page = clara.page;
|
||||||
|
await page.setViewportSize(vp);
|
||||||
|
await page.goto(L.homes.clerical);
|
||||||
|
await page.getByRole("button", { name: L.newPacket }).first().click();
|
||||||
|
const sheet = page.getByRole("dialog").first();
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
await page.waitForTimeout(400); // the sheet's slide in
|
||||||
|
const m = await sheet.evaluate((el) => {
|
||||||
|
const b = el.getBoundingClientRect();
|
||||||
|
const cs = getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
top: Math.round(b.top),
|
||||||
|
bottom: Math.round(window.innerHeight - b.bottom),
|
||||||
|
left: Math.round(b.left),
|
||||||
|
right: Math.round(window.innerWidth - b.right),
|
||||||
|
tall: el.scrollHeight > el.clientHeight + 1,
|
||||||
|
scrolls: cs.overflowY === "auto" || cs.overflowY === "scroll",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(m.top, JSON.stringify(m)).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(m.left, JSON.stringify(m)).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(m.right, JSON.stringify(m)).toBeGreaterThanOrEqual(8);
|
||||||
|
if (vp.width > 700) expect(m.bottom, JSON.stringify(m)).toBeGreaterThanOrEqual(8);
|
||||||
|
else expect(m.bottom, JSON.stringify(m)).toBeGreaterThanOrEqual(0); // the phone sheet sits on the bottom edge
|
||||||
|
if (m.tall) expect(m.scrolls, "a tall sheet scrolls inside").toBe(true);
|
||||||
|
await closeAll(clara);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tmotion: the switch, reduced motion, and a card moved by someone else glides", async ({ browser }) => {
|
||||||
|
// reduced motion always wins
|
||||||
|
const calm = await browser.newContext({ reducedMotion: "reduce" });
|
||||||
|
const calmPage = await calm.newPage();
|
||||||
|
await signInPassword(calmPage, "sup.wes");
|
||||||
|
await calmPage.goto(L.homes.supervisor);
|
||||||
|
await expect(searchBox(calmPage)).toBeVisible();
|
||||||
|
expect(await calmPage.evaluate(() => document.body.classList.contains("motion"))).toBe(false);
|
||||||
|
await calm.close();
|
||||||
|
|
||||||
|
const wes = await actor(browser, "sup.wes");
|
||||||
|
const page = wes.page;
|
||||||
|
await page.evaluate(() => window.localStorage.removeItem("ps.motion"));
|
||||||
|
await supervisorHome(page);
|
||||||
|
await expect.poll(() => page.evaluate(() => document.body.classList.contains("motion"))).toBe(true);
|
||||||
|
const btn = page.getByRole("banner").getByRole("button", { name: new RegExp(`^(${L.motionOn}|${L.motionOff})$`) });
|
||||||
|
await expect(btn).toHaveText(L.motionOn);
|
||||||
|
await btn.click();
|
||||||
|
await expect(btn).toHaveText(L.motionOff);
|
||||||
|
await expect(btn).toHaveAttribute("aria-pressed", "false");
|
||||||
|
expect(await page.evaluate(() => document.body.classList.contains("motion"))).toBe(false);
|
||||||
|
expect(await lsGet(page, "ps.motion")).toBe("off");
|
||||||
|
await btn.click();
|
||||||
|
await expect(btn).toHaveText(L.motionOn);
|
||||||
|
expect(await page.evaluate(() => document.body.classList.contains("motion"))).toBe(true);
|
||||||
|
expect(await lsGet(page, "ps.motion")).not.toBe("off");
|
||||||
|
|
||||||
|
// at rest: no card animates on a plain refresh
|
||||||
|
await pickProject(page, PROJECT);
|
||||||
|
await expect(cardOf(page, F.f3)).toBeVisible();
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
expect(await cardAnimations(page)).toBe(0);
|
||||||
|
// lead.lou assigns F3 through the API: the card moves to Stall 3 on wes's board and glides
|
||||||
|
const lou = await actor(browser, "lead.lou");
|
||||||
|
const stalls = (await api(lou, "get", "/api/shop/stations")) as unknown as Array<Record<string, unknown>>;
|
||||||
|
const stall3 = Number(stalls.find((s) => s.dept_code === "03" && s.name === "Stall 3")?.id ?? 0);
|
||||||
|
await stepApi(lou, F.f3.id, "assign", { station_id: stall3, welder_id: andyId });
|
||||||
|
await expect.poll(() => cardAnimations(page), { timeout: 10_000, intervals: [50] }).toBeGreaterThan(0);
|
||||||
|
await page.waitForTimeout(2_000);
|
||||||
|
expect(await cardAnimations(page)).toBe(0);
|
||||||
|
await closeAll(wes, lou);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { actor, api, closeAll, packet, stepApi } from "./shophelpers";
|
||||||
|
import {
|
||||||
|
L,
|
||||||
|
catchOpen,
|
||||||
|
downloadText,
|
||||||
|
isoDay,
|
||||||
|
makeDraft,
|
||||||
|
makeProject,
|
||||||
|
makeUnit,
|
||||||
|
personId,
|
||||||
|
pickProject,
|
||||||
|
pmDataRows,
|
||||||
|
pmTab,
|
||||||
|
pmTable,
|
||||||
|
pullWithHeat,
|
||||||
|
runSuffix,
|
||||||
|
unpickProject,
|
||||||
|
type Made,
|
||||||
|
type Unit,
|
||||||
|
} from "./pmhelpers";
|
||||||
|
|
||||||
|
// Phase 8 Project Management on the CI stack, modelled on the old tests/ps tpm. clerk.clara makes a project with
|
||||||
|
// two units (one due yesterday, one in four days) and three packets through the API: K1 put on hold by wh.wade,
|
||||||
|
// K2 assigned to weld.andy and started (in progress), K3 Ready to assign. pm.paula signs in and lands on /pm: the
|
||||||
|
// KPI tiles and the plain-words summary; the projects tab opens the project to its units and a unit drills to Find
|
||||||
|
// jobs; the aging, hours and quality tabs have rows; the scope follows the find bar's project pick; Excel (CSV)
|
||||||
|
// — all downloads a CSV whose first line names the report; PDF report answers a PDF; a packet shows View only and
|
||||||
|
// no enabled write control; the API refuses her a write. Every UI label used here is in L (pmhelpers.ts).
|
||||||
|
|
||||||
|
let RUN = "";
|
||||||
|
let PROJECT = "";
|
||||||
|
let units: Unit[] = [];
|
||||||
|
const K: Record<"k1" | "k2" | "k3", Made> = {} as Record<"k1" | "k2" | "k3", Made>;
|
||||||
|
|
||||||
|
test.describe.serial("project management", () => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
|
||||||
|
test("clerk.clara, wh.wade and weld.andy make the PM packets", async ({ browser }, info) => {
|
||||||
|
RUN = runSuffix(info.project.name);
|
||||||
|
PROJECT = `PM${RUN}`;
|
||||||
|
const clara = await actor(browser, "clerk.clara");
|
||||||
|
const wade = await actor(browser, "wh.wade");
|
||||||
|
const wes = await actor(browser, "sup.wes");
|
||||||
|
const andy = await actor(browser, "weld.andy");
|
||||||
|
const proj = await makeProject(clara, PROJECT, { name: "PM pump skids", customer: "CI Gas", due: isoDay(20) });
|
||||||
|
units = [
|
||||||
|
await makeUnit(clara, Number(proj.id), "001", isoDay(-1)),
|
||||||
|
await makeUnit(clara, Number(proj.id), "002", isoDay(4)),
|
||||||
|
];
|
||||||
|
const [u1, u2] = units as [Unit, Unit];
|
||||||
|
K.k1 = await makeDraft(clara, u1, `PM held ${RUN}`, { send: "warehouse" });
|
||||||
|
await api(wade, "post", `/api/packets/${K.k1.id}/wh`, {
|
||||||
|
stage: "hold",
|
||||||
|
reason: "Waiting on purchasing / PO",
|
||||||
|
note: "PO 4471",
|
||||||
|
expect_stage: "incoming",
|
||||||
|
});
|
||||||
|
K.k2 = await makeDraft(clara, u1, `PM running ${RUN}`, { send: "warehouse" });
|
||||||
|
await pullWithHeat(wade, K.k2.id, `HP${RUN}-`);
|
||||||
|
const andyId = await personId(wes, "weld.andy");
|
||||||
|
const stalls = (await api(wes, "get", "/api/shop/stations")) as unknown as Array<Record<string, unknown>>;
|
||||||
|
const stall2 = stalls.find((s) => s.dept_code === "03" && s.name === "Stall 2");
|
||||||
|
expect(stall2).toBeTruthy();
|
||||||
|
await stepApi(wes, K.k2.id, "assign", { station_id: Number(stall2?.id), welder_id: andyId });
|
||||||
|
await stepApi(andy, K.k2.id, "start");
|
||||||
|
K.k3 = await makeDraft(clara, u2, `PM ready ${RUN}`, { send: "03" });
|
||||||
|
expect((await packet(wes, K.k2.id)).status).toBe("in_progress");
|
||||||
|
await closeAll(clara, wade, wes, andy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tpm: overview, projects, drill down, tabs, scope, exports, view only", async ({ browser }) => {
|
||||||
|
const paula = await actor(browser, "pm.paula");
|
||||||
|
const page = paula.page;
|
||||||
|
|
||||||
|
// 1. lands on the PM screen: KPIs and the summary
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${L.homes.pm}$`));
|
||||||
|
await expect(page.getByText(L.pm.eyebrow, { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: L.pm.head })).toBeVisible();
|
||||||
|
for (const label of L.pm.kpis) {
|
||||||
|
await expect(page.getByRole("button", { name: label }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
const sum = page.locator(".pmsum").or(page.getByTestId("pm-summary")).first();
|
||||||
|
await expect(sum.getByRole("heading", { name: L.pm.plainWords })).toBeVisible();
|
||||||
|
await expect(sum.getByRole("listitem").first()).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: L.pm.pipeline, exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("img", { name: L.pm.bars })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: L.pm.attention })).toBeVisible();
|
||||||
|
await expect(pmTab(page, L.pm.tabs.overview)).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(page.getByText(L.pm.scope).first()).toBeVisible();
|
||||||
|
|
||||||
|
// 2. the projects tab: open the project to its units
|
||||||
|
await pmTab(page, L.pm.tabs.projects).click();
|
||||||
|
const projects = pmTable(page, L.pm.titles.projects);
|
||||||
|
await expect(projects).toBeVisible();
|
||||||
|
const projRow = projects.getByRole("row").filter({ hasText: PROJECT }).first();
|
||||||
|
await expect(projRow).toBeVisible();
|
||||||
|
const unit1 = projects.getByRole("row").filter({ hasText: `${PROJECT}001` });
|
||||||
|
if (!(await unit1.count())) await projRow.click();
|
||||||
|
await expect(unit1.first()).toBeVisible();
|
||||||
|
await expect(projects.getByRole("row").filter({ hasText: `${PROJECT}002` }).first()).toBeVisible();
|
||||||
|
await expect(unit1.first()).toContainText(L.pm.health.late);
|
||||||
|
|
||||||
|
// 3. a unit drills to Find jobs, scoped to that unit
|
||||||
|
await unit1.first().click();
|
||||||
|
await expect(pmTab(page, L.pm.tabs.find)).toHaveAttribute("aria-selected", "true");
|
||||||
|
const list = page.getByRole("table", { name: L.find.listHead, exact: true });
|
||||||
|
await expect(list.getByRole("row").filter({ hasText: K.k1.title }).first()).toBeVisible();
|
||||||
|
await expect(list.getByRole("row").filter({ hasText: K.k2.title }).first()).toBeVisible();
|
||||||
|
await expect(list.getByRole("row").filter({ hasText: K.k3.title })).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("pm-scope").or(page.locator(".pmscope")).first()).toContainText(
|
||||||
|
`Unit ${PROJECT}001`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. aging, hours and quality have rows
|
||||||
|
await unpickProject(page);
|
||||||
|
for (const t of [L.pm.tabs.aging, L.pm.tabs.hours, L.pm.tabs.quality]) {
|
||||||
|
await pmTab(page, t).click();
|
||||||
|
await expect(pmTab(page, t)).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(page.locator("section.pmt").first()).toBeVisible();
|
||||||
|
await expect.poll(() => pmDataRows(page).count(), { message: t }).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
await expect(pmTable(page, L.pm.titles.qualityHolds).getByRole("row").filter({ hasText: PROJECT })).not.toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. the scope follows the find bar
|
||||||
|
await pickProject(page, PROJECT);
|
||||||
|
await pmTab(page, L.pm.tabs.overview).click();
|
||||||
|
const scope = page.getByTestId("pm-scope").or(page.locator(".pmscope")).first();
|
||||||
|
await expect(scope).toContainText(`Project ${PROJECT}`);
|
||||||
|
await expect(scope).toContainText("view only");
|
||||||
|
await expect(page.getByTestId("pm-kpi-holds").or(page.getByRole("button", { name: /Warehouse holds/ })).first()).toContainText("1");
|
||||||
|
|
||||||
|
// 6. exports: the CSV of every table downloads; the PDF answers a PDF
|
||||||
|
const [dl] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
page.getByRole("button", { name: L.pm.csvAll, exact: true }).click(),
|
||||||
|
]);
|
||||||
|
expect(dl.suggestedFilename()).toMatch(/^PacketStation_All_reports_\d{4}-\d{2}-\d{2}\.csv$/);
|
||||||
|
const text = await downloadText(dl);
|
||||||
|
expect(text.split(/\r\n/)[0]).toMatch(L.pm.csvFirst);
|
||||||
|
expect(text).toContain(`Project ${PROJECT}`);
|
||||||
|
const url = await catchOpen(page, page.getByRole("button", { name: L.pm.pdf, exact: true }));
|
||||||
|
expect(url).toContain("/api/pm/report.pdf");
|
||||||
|
const r = await page.request.get(url);
|
||||||
|
expect(r.status()).toBe(200);
|
||||||
|
expect(r.headers()["content-type"]).toContain("application/pdf");
|
||||||
|
expect((await r.body()).subarray(0, 4).toString("latin1")).toBe("%PDF");
|
||||||
|
|
||||||
|
// 7. read only: View only and no enabled write control; the API refuses a write
|
||||||
|
await page.goto(`/packets/${K.k2.id}`);
|
||||||
|
await expect(page.getByText(L.pm.viewOnly).first()).toBeVisible();
|
||||||
|
const writes = page
|
||||||
|
.getByRole("button", {
|
||||||
|
name: /^(Pause|Resume|Start work|Submit to supervisor|Send to station|Send to Q\.C\.|Mark a weld|Return to station|Pull back to queue)$/,
|
||||||
|
})
|
||||||
|
.and(page.locator(":enabled"));
|
||||||
|
await expect(writes).toHaveCount(0);
|
||||||
|
await expect(page.locator("main input:enabled[type=checkbox]")).toHaveCount(0);
|
||||||
|
await stepApi(paula, K.k2.id, "submit", {}, 403);
|
||||||
|
await api(paula, "post", `/api/pm/overview`, {}, { status: 405 });
|
||||||
|
await closeAll(paula);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tpm: the period changes the history figures", async ({ browser }) => {
|
||||||
|
const paula = await actor(browser, "pm.paula");
|
||||||
|
const page = paula.page;
|
||||||
|
await page.goto(L.homes.pm);
|
||||||
|
const period = page.getByLabel(L.pm.period).or(page.getByTitle("Period for history-based figures")).first();
|
||||||
|
await period.selectOption({ label: L.pm.periods["365"] });
|
||||||
|
await expect(page.getByRole("button", { name: /^Q\.C\. rejects · last 12 months/ }).first()).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => window.localStorage.getItem("ps.pmper"))).toBe("365");
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("button", { name: /^Filed · last 12 months/ }).first()).toBeVisible();
|
||||||
|
await period.selectOption({ label: L.pm.periods["90"] });
|
||||||
|
await pmTab(page, L.pm.tabs.hours).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(pmTab(page, L.pm.tabs.hours)).toHaveAttribute("aria-selected", "true"); // ps.pmtab
|
||||||
|
await closeAll(paula);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { expect, type Download, type Locator, type Page } from "@playwright/test";
|
||||||
|
import { dcUser } from "./helpers";
|
||||||
|
import { api, sign, stepApi, type Actor } from "./shophelpers";
|
||||||
|
import { getJson, onePagePdf, writeHeaders, type Json } from "./whhelpers";
|
||||||
|
|
||||||
|
// Helpers for the phase 8 scenarios (Project Management, the find bar, presence, motion, Back and the sheets):
|
||||||
|
// projects, units and packets made through the API, the find bar and PM locators, and the UI labels the specs
|
||||||
|
// depend on (L). The labels are the texts of the phase 8 design contract ("Labels for the Playwright specs" and the
|
||||||
|
// vocabulary); the main session adjusts them here in one place.
|
||||||
|
|
||||||
|
export const L = {
|
||||||
|
// Project Management
|
||||||
|
pm: {
|
||||||
|
eyebrow: "Project management · Status and reporting",
|
||||||
|
head: "Where every job stands",
|
||||||
|
tabs: {
|
||||||
|
overview: "Overview",
|
||||||
|
projects: "Projects & units",
|
||||||
|
aging: "Bottlenecks & aging",
|
||||||
|
hours: "Hours vs target",
|
||||||
|
quality: "Quality & issues",
|
||||||
|
find: "Find jobs",
|
||||||
|
},
|
||||||
|
pdf: "PDF report",
|
||||||
|
csvAll: "Excel (CSV) — all",
|
||||||
|
csv: "Excel (CSV)",
|
||||||
|
details: "Details",
|
||||||
|
kpis: [
|
||||||
|
"Open packets",
|
||||||
|
"Late units",
|
||||||
|
"Units at risk",
|
||||||
|
"Warehouse holds",
|
||||||
|
"Open change orders",
|
||||||
|
"Open shorts",
|
||||||
|
/Q\.C\. rejects · /,
|
||||||
|
"Packets over target hours",
|
||||||
|
/Filed · /,
|
||||||
|
"Average lead time, created → filed",
|
||||||
|
] as (string | RegExp)[],
|
||||||
|
plainWords: "In plain words",
|
||||||
|
pipeline: "Pipeline",
|
||||||
|
bars: "Open packets by step",
|
||||||
|
attention: "Units late or at risk",
|
||||||
|
scope: "Scope:",
|
||||||
|
nothing: "Nothing in scope.",
|
||||||
|
allOnTrack: "Every unit with packets is on track.",
|
||||||
|
period: "Period",
|
||||||
|
periods: { "30": "Last 30 days", "90": "Last 90 days", "365": "Last 12 months", all: "All time" },
|
||||||
|
health: { ok: "On track", risk: "At risk", late: "Late", done: "Complete", empty: "No packets yet" },
|
||||||
|
viewOnly: "View only.",
|
||||||
|
titles: {
|
||||||
|
projects: "Projects and units",
|
||||||
|
agingSteps: "Time in the current step (open packets)",
|
||||||
|
agingOpen: "Every open packet, longest waiting first",
|
||||||
|
hoursDept: "By department",
|
||||||
|
hoursOver: "Packets over target",
|
||||||
|
qualityHolds: "Warehouse holds",
|
||||||
|
qualityShorts: "Open shorts",
|
||||||
|
},
|
||||||
|
csvFirst: /^PESCO Packet Station — /,
|
||||||
|
},
|
||||||
|
// the find bar
|
||||||
|
find: {
|
||||||
|
bar: "findbar", // data-testid of the bar (a region named "Find jobs")
|
||||||
|
search: "Find a job",
|
||||||
|
project: "Project", // the project search box of PickUnit
|
||||||
|
projectsFound: "Projects found",
|
||||||
|
change: "Change",
|
||||||
|
unit: "Unit",
|
||||||
|
filters: "Filters",
|
||||||
|
board: "Board",
|
||||||
|
list: "List",
|
||||||
|
dept: "Department",
|
||||||
|
status: "Status",
|
||||||
|
station: "Station",
|
||||||
|
worker: "Worker",
|
||||||
|
flag: "Flag",
|
||||||
|
statusAtStation: "At a station (assigned / working)",
|
||||||
|
chips: {
|
||||||
|
co: "Change order open",
|
||||||
|
short: "Shorts open",
|
||||||
|
overdue: "Project overdue",
|
||||||
|
week: "Due within 7 days",
|
||||||
|
mine: "Mine",
|
||||||
|
},
|
||||||
|
showing: "Showing",
|
||||||
|
count: "flt-count", // data-testid of "Showing n packets"
|
||||||
|
clear: /^Clear filters/,
|
||||||
|
listHead: "Jobs",
|
||||||
|
sortBy: "Sort by",
|
||||||
|
sortProject: "Project / lot",
|
||||||
|
empty: "No packets match these filters.",
|
||||||
|
},
|
||||||
|
// KPI tiles on the Supervisor home
|
||||||
|
kpi: {
|
||||||
|
ready: "Ready to assign",
|
||||||
|
atStations: "At stations",
|
||||||
|
review: "Waiting on review",
|
||||||
|
},
|
||||||
|
homes: {
|
||||||
|
supervisor: "/supervisor",
|
||||||
|
clerical: "/clerical",
|
||||||
|
pm: "/pm",
|
||||||
|
},
|
||||||
|
newPacket: "New packet",
|
||||||
|
back: /Back$/,
|
||||||
|
presence: "presence", // data-testid of the callout (role status)
|
||||||
|
alsoHere: "Also in this packet now:",
|
||||||
|
roles: { supervisor: "Supervisor", stall: "Weld Stall", pm: "Project Mgmt" },
|
||||||
|
motionOn: "Motion on",
|
||||||
|
motionOff: "Motion off",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** A run suffix that differs per Playwright project, so the chromium and ipad runs never share a number. */
|
||||||
|
export function runSuffix(projectName: string): string {
|
||||||
|
// Letters only, so no digit in it can match a lot or a count the specs search for ("lot 54").
|
||||||
|
const base = Date.now()
|
||||||
|
.toString(36)
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(-5)
|
||||||
|
.replace(/\d/g, (d) => "QRSTUVWXYZ".charAt(Number(d)));
|
||||||
|
return projectName === "chromium" ? base : `${base}${projectName.slice(0, 1).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today plus `days`, as YYYY-MM-DD (UTC calendar; the specs only need a day or more of margin). */
|
||||||
|
export function isoDay(days: number): string {
|
||||||
|
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Unit {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
lot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Made {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
unitNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As Clerical: a project (made once per number) with the given fields. */
|
||||||
|
export async function makeProject(clara: Actor, number: string, fields: Json = {}): Promise<Json> {
|
||||||
|
const projects = (await getJson(clara.page.request, "/api/projects?status=all")) as unknown as Json[];
|
||||||
|
const found = projects.find((p) => p.number === number);
|
||||||
|
if (found) return found;
|
||||||
|
return api(clara, "post", "/api/projects", { number, name: `CI ${number}`, ...fields }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As Clerical: one unit with this lot (and due date) in the project. */
|
||||||
|
export async function makeUnit(clara: Actor, projectId: number, lot: string, due?: string): Promise<Unit> {
|
||||||
|
const units = (await api(
|
||||||
|
clara,
|
||||||
|
"post",
|
||||||
|
`/api/projects/${projectId}/units`,
|
||||||
|
{ lot, ...(due ? { due } : {}) },
|
||||||
|
{ status: 201 },
|
||||||
|
)) as unknown as Json[];
|
||||||
|
const u = units[0] as Json;
|
||||||
|
return { id: Number(u.id), number: String(u.number), lot: String(u.lot ?? lot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftOpts {
|
||||||
|
dept?: string;
|
||||||
|
customer?: string;
|
||||||
|
wo?: string;
|
||||||
|
drawing?: string;
|
||||||
|
bom?: Json[];
|
||||||
|
/** "warehouse" sends it to the warehouse; a department code releases it there; absent leaves a bare draft. */
|
||||||
|
send?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOM3: Json[] = [
|
||||||
|
{ item: 1, qty: 1, part_id: "PIPE-4", description: "4in pipe SCH40" },
|
||||||
|
{ item: 2, qty: 2, part_id: "FLG-4", description: "4in flange" },
|
||||||
|
{ item: 3, qty: 1, part_id: "STUD-58", description: "5/8 stud bolt" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As clerk.clara: a hand draft on `unit` with a work order (target 2 h) and three BOM lines. With `send`, a one-page
|
||||||
|
* drawing is attached with her password and the heat map answered, then it is sent on. A bare draft is one request.
|
||||||
|
*/
|
||||||
|
export async function makeDraft(clara: Actor, unit: Unit, title: string, o: DraftOpts = {}): Promise<Made> {
|
||||||
|
const draft = await api(
|
||||||
|
clara,
|
||||||
|
"post",
|
||||||
|
"/api/packets",
|
||||||
|
{
|
||||||
|
unit_id: unit.id,
|
||||||
|
dept_code: o.dept ?? "03",
|
||||||
|
op: "WELD-PART",
|
||||||
|
title,
|
||||||
|
customer: o.customer ?? "CI Customer",
|
||||||
|
drawing: o.drawing ?? `D-${unit.number}`,
|
||||||
|
rev: "A",
|
||||||
|
wps: "WPS-11",
|
||||||
|
work_orders: [
|
||||||
|
{
|
||||||
|
wo_base: (o.wo ?? `W${unit.number}`).slice(0, 20),
|
||||||
|
lot: "001",
|
||||||
|
split: "0",
|
||||||
|
sub: "0",
|
||||||
|
qty: 1,
|
||||||
|
target_hrs: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bom: o.bom ?? BOM3,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
const id = Number(draft.id);
|
||||||
|
if (o.send) {
|
||||||
|
const up = await clara.page.request.post("/api/assets", {
|
||||||
|
headers: writeHeaders(clara.page),
|
||||||
|
multipart: {
|
||||||
|
file: { name: `${unit.number}-${id}.pdf`, mimeType: "application/pdf", buffer: onePagePdf() },
|
||||||
|
purpose: "drawing",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(up.status(), await up.text()).toBe(201);
|
||||||
|
const asset = (await up.json()) as Json;
|
||||||
|
await api(clara, "post", `/api/packets/${id}/pages`, {
|
||||||
|
asset_id: asset.id,
|
||||||
|
type: "drawing",
|
||||||
|
sign: sign("clerk.clara"),
|
||||||
|
});
|
||||||
|
await api(clara, "patch", `/api/packets/${id}`, { set: { heat_map: true }, base: {} });
|
||||||
|
if (o.send === "warehouse") {
|
||||||
|
const sent = await stepApi(clara, id, "send_to_warehouse");
|
||||||
|
expect(sent.status).toBe("warehouse");
|
||||||
|
} else {
|
||||||
|
const sent = await stepApi(clara, id, "release_to_shop", { dept: o.send });
|
||||||
|
expect(sent.status).toBe("ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id, title, unitNumber: unit.number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As wh.wade: every BOM line pulled with `heat` (line n gets `${heat}${n}`), except the items in `short`, which are
|
||||||
|
* recorded short (On order); then released. The packet is Ready to assign in 03.
|
||||||
|
*/
|
||||||
|
export async function pullWithHeat(wade: Actor, id: number, heat: string, short: number[] = []): Promise<Json> {
|
||||||
|
let p = await getJson(wade.page.request, `/api/packets/${id}`);
|
||||||
|
for (const line of p.bom as Json[]) {
|
||||||
|
const item = Number(line.item);
|
||||||
|
if (short.includes(item)) {
|
||||||
|
p = await api(wade, "post", `/api/packets/${id}/bom/${item}/short`, {
|
||||||
|
qty: Number(line.qty ?? 1),
|
||||||
|
reason: "On order",
|
||||||
|
expect_open: false,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cur = (p.bom as Json[]).find((b) => b.item === item) as Json;
|
||||||
|
p = await api(
|
||||||
|
wade,
|
||||||
|
"patch",
|
||||||
|
`/api/packets/${id}/bom/${item}`,
|
||||||
|
{ pulled: true },
|
||||||
|
{ headers: { "If-Match": String(cur.row_version) } },
|
||||||
|
);
|
||||||
|
cur = (p.bom as Json[]).find((b) => b.item === item) as Json;
|
||||||
|
p = await api(
|
||||||
|
wade,
|
||||||
|
"patch",
|
||||||
|
`/api/packets/${id}/bom/${item}`,
|
||||||
|
{ heat: `${heat}${item}` },
|
||||||
|
{ headers: { "If-Match": String(cur.row_version) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const out = await stepApi(wade, id, "wh_release");
|
||||||
|
expect(out.status).toBe("ready");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user id of a 03 person by display name (the assign sheet's people list). */
|
||||||
|
export async function personId(a: Actor, sam: string): Promise<string> {
|
||||||
|
const people = (await getJson(a.page.request, "/api/departments/03/people")) as unknown as Json[];
|
||||||
|
const me = people.find((x) => x.display_name === dcUser(sam).display_name);
|
||||||
|
if (!me) throw new Error(`${sam} is not in 03 people`);
|
||||||
|
return String(me.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- the find bar
|
||||||
|
|
||||||
|
export function findBar(page: Page): Locator {
|
||||||
|
return page.getByTestId(L.find.bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchBox(page: Page): Locator {
|
||||||
|
return findBar(page).getByRole("searchbox", { name: L.find.search });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick a project in the find bar (PickUnit: search, then the hit). Unpicks the current one first. */
|
||||||
|
export async function pickProject(page: Page, number: string): Promise<void> {
|
||||||
|
const bar = findBar(page);
|
||||||
|
const change = bar.getByRole("button", { name: L.find.change, exact: true });
|
||||||
|
if (await change.count()) await change.first().click();
|
||||||
|
await bar.getByRole("searchbox", { name: L.find.project }).fill(number);
|
||||||
|
await bar
|
||||||
|
.getByRole("group", { name: L.find.projectsFound })
|
||||||
|
.getByRole("button", { name: new RegExp(`^${number}\\b`) })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(bar.getByText(number, { exact: true }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to every project. */
|
||||||
|
export async function unpickProject(page: Page): Promise<void> {
|
||||||
|
const change = findBar(page).getByRole("button", { name: L.find.change, exact: true });
|
||||||
|
if (await change.count()) await change.first().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The n of "Showing n packets". */
|
||||||
|
export async function showing(page: Page): Promise<number> {
|
||||||
|
const t = (await page.getByTestId(L.find.count).innerText()).replace(/\s+/g, " ");
|
||||||
|
const m = /Showing (\d+)/.exec(t);
|
||||||
|
if (!m) throw new Error(`no count in "${t}"`);
|
||||||
|
return Number(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait until the bar shows `n` packets. */
|
||||||
|
export async function expectShowing(page: Page, n: number): Promise<void> {
|
||||||
|
await expect.poll(() => showing(page), { timeout: 10_000 }).toBe(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A select of the find bar's second row by its label. */
|
||||||
|
export function fltSelect(page: Page, label: string): Locator {
|
||||||
|
return findBar(page).getByLabel(label, { exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filtersButton(page: Page): Locator {
|
||||||
|
return findBar(page).getByRole("button", { name: new RegExp(`^${L.find.filters}`) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chip(page: Page, label: string): Locator {
|
||||||
|
return findBar(page).getByRole("button", { name: label, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewButton(page: Page, which: "Board" | "List"): Locator {
|
||||||
|
return findBar(page).getByRole("button", { name: which, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobList(page: Page): Locator {
|
||||||
|
return page.getByRole("table", { name: L.find.listHead, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The packet ids of the list view's rows, in order (each row carries data-card-id). */
|
||||||
|
export async function listIds(page: Page): Promise<number[]> {
|
||||||
|
const rows = jobList(page).locator("tbody tr[data-card-id]");
|
||||||
|
return (await rows.evaluateAll((els) => els.map((e) => Number(e.getAttribute("data-card-id"))))) as number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A card of a packet anywhere on the board: by data-card-id, else by its title or unit number. */
|
||||||
|
export function cardOf(page: Page, p: Made): Locator {
|
||||||
|
return page
|
||||||
|
.locator(`[data-card-id="${p.id}"]`)
|
||||||
|
.or(page.locator(".card").filter({ hasText: p.title }))
|
||||||
|
.or(page.locator(".card").filter({ hasText: p.unitNumber }))
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lsGet(page: Page, key: string): Promise<string | null> {
|
||||||
|
return page.evaluate((k) => window.localStorage.getItem(k), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- PM
|
||||||
|
|
||||||
|
export function pmTab(page: Page, name: string): Locator {
|
||||||
|
return page.getByRole("tablist").getByRole("tab", { name, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pmTable(page: Page, title: string): Locator {
|
||||||
|
return page.getByRole("table", { name: title, exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body rows of every PM table on the page that carry data (not the "Nothing in scope." row). */
|
||||||
|
export function pmDataRows(page: Page): Locator {
|
||||||
|
return page.locator("table.pmtable tbody tr").filter({ hasNotText: L.pm.nothing });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a finished download as text, without the BOM. */
|
||||||
|
export async function downloadText(dl: Download): Promise<string> {
|
||||||
|
const path = await dl.path();
|
||||||
|
if (!path) throw new Error("download has no path");
|
||||||
|
const text = readFileSync(path, "utf-8");
|
||||||
|
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click and catch what it opens in a new tab (window.open is stubbed), as a URL. */
|
||||||
|
export async function catchOpen(page: Page, target: Locator): Promise<string> {
|
||||||
|
await expect(target).toBeVisible();
|
||||||
|
const href = await target.getAttribute("href");
|
||||||
|
if (href) return new URL(href, page.url()).toString();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const w = window as unknown as { __opened: string[]; open: (u?: string | URL) => Window | null };
|
||||||
|
w.__opened = [];
|
||||||
|
w.open = (u?: string | URL) => {
|
||||||
|
w.__opened.push(String(u ?? ""));
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await target.click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened)).length)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
const opened = await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened);
|
||||||
|
return new URL(opened[0] ?? "", page.url()).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many Web Animations run on board cards right now (the FLIP glide, the fade in, the glow). */
|
||||||
|
export async function cardAnimations(page: Page): Promise<number> {
|
||||||
|
return page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.getAnimations().filter((a) => {
|
||||||
|
const t = (a.effect as KeyframeEffect | null)?.target as Element | null | undefined;
|
||||||
|
return !!t && !!t.closest && !!t.closest("[data-card-id]");
|
||||||
|
}).length,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { dcUser } from "./helpers";
|
||||||
|
import { actor, api, closeAll } from "./shophelpers";
|
||||||
|
import { L, makeDraft, makeProject, makeUnit, pullWithHeat, runSuffix, type Made } from "./pmhelpers";
|
||||||
|
|
||||||
|
// Phase 8 presence on the CI stack, modelled on the old sync.js presence and the "Also here" line. clerk.clara and
|
||||||
|
// wh.wade make one packet Ready to assign in 03. sup.wes and weld.andy open it in two browser contexts: each sees
|
||||||
|
// "Also in this packet now:" with the other's name and role. weld.andy closes the page: the callout on sup.wes's
|
||||||
|
// page clears within 10 s. weld.andy opens it again, then sup.wes locks the session through the API: weld.andy's
|
||||||
|
// callout drops sup.wes. Every UI label used here is in L (pmhelpers.ts).
|
||||||
|
|
||||||
|
let RUN = "";
|
||||||
|
let P: Made;
|
||||||
|
|
||||||
|
function callout(page: Page) {
|
||||||
|
return page.getByTestId(L.presence);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPacket(page: Page, id: number): Promise<void> {
|
||||||
|
await page.goto(`/packets/${id}`);
|
||||||
|
await expect(page.getByRole("heading").first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.serial("presence", () => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
test("clerk.clara and wh.wade make the packet", async ({ browser }, info) => {
|
||||||
|
RUN = runSuffix(info.project.name);
|
||||||
|
const clara = await actor(browser, "clerk.clara");
|
||||||
|
const wade = await actor(browser, "wh.wade");
|
||||||
|
const proj = await makeProject(clara, `PR${RUN}`, { name: "Presence job" });
|
||||||
|
const unit = await makeUnit(clara, Number(proj.id), "001");
|
||||||
|
P = await makeDraft(clara, unit, `Presence ${RUN}`, { send: "warehouse" });
|
||||||
|
await pullWithHeat(wade, P.id, `HP${RUN}-`);
|
||||||
|
await closeAll(clara, wade);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two people on one packet see each other; leaving and locking drop out", async ({ browser }) => {
|
||||||
|
const wes = await actor(browser, "sup.wes");
|
||||||
|
const andy = await actor(browser, "weld.andy");
|
||||||
|
const wesName = dcUser("sup.wes").display_name;
|
||||||
|
const andyName = dcUser("weld.andy").display_name;
|
||||||
|
|
||||||
|
// alone: nothing is drawn
|
||||||
|
await openPacket(wes.page, P.id);
|
||||||
|
await expect(callout(wes.page)).toHaveCount(0);
|
||||||
|
|
||||||
|
// together: each sees the other with the role
|
||||||
|
await openPacket(andy.page, P.id);
|
||||||
|
await expect(callout(wes.page)).toContainText(L.alsoHere, { timeout: 10_000 });
|
||||||
|
await expect(callout(wes.page)).toContainText(`${andyName} (${L.roles.stall})`);
|
||||||
|
await expect(callout(andy.page)).toContainText(L.alsoHere, { timeout: 10_000 });
|
||||||
|
await expect(callout(andy.page)).toContainText(`${wesName} (${L.roles.supervisor})`);
|
||||||
|
await expect(callout(wes.page)).toHaveAttribute("role", "status");
|
||||||
|
await expect(callout(wes.page)).not.toContainText(wesName); // never yourself
|
||||||
|
|
||||||
|
// weld.andy closes the page: gone within 10 s
|
||||||
|
await andy.page.close();
|
||||||
|
await expect(callout(wes.page).filter({ hasText: andyName })).toHaveCount(0, { timeout: 10_000 });
|
||||||
|
|
||||||
|
// back again, then sup.wes locks: gone from weld.andy's callout
|
||||||
|
andy.page = await andy.ctx.newPage();
|
||||||
|
await openPacket(andy.page, P.id);
|
||||||
|
await expect(callout(andy.page)).toContainText(wesName, { timeout: 10_000 });
|
||||||
|
await api(wes, "post", "/api/auth/lock", undefined, { status: 204 });
|
||||||
|
await expect(callout(andy.page).filter({ hasText: wesName })).toHaveCount(0, { timeout: 10_000 });
|
||||||
|
await closeAll(wes, andy);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -192,7 +192,8 @@ test.describe.serial("warehouse", () => {
|
|||||||
await rs.getByLabel(L.heat, { exact: true }).fill("HT-E2E-R");
|
await rs.getByLabel(L.heat, { exact: true }).fill("HT-E2E-R");
|
||||||
await rs.getByRole("button", { name: L.receive, exact: true }).click();
|
await rs.getByRole("button", { name: L.receive, exact: true }).click();
|
||||||
await expect(rs).toBeHidden();
|
await expect(rs).toBeHidden();
|
||||||
await expect(page.getByText(L.noShorts)).toBeVisible();
|
// Other scenarios may leave shorts of their own on the table; this packet's row is gone.
|
||||||
|
await expect(shortRow).toHaveCount(0);
|
||||||
const after = await getJson(page.request, `/api/packets/${pullPkt.id}`);
|
const after = await getJson(page.request, `/api/packets/${pullPkt.id}`);
|
||||||
expect(after.open_shorts).toBe(0);
|
expect(after.open_shorts).toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
"""Helpers for the phase 8 tests: one database per test module, the Project Management world, and the texts of the
|
||||||
|
phase 8 contract (periods, PM steps, health, KPI labels, table titles and heads).
|
||||||
|
|
||||||
|
Built on shophelpers, qchelpers and closehelpers (imported, never edited). Nothing here reads VISUAL or M-Files.
|
||||||
|
The labor hours are rows written straight into `visual_labor`, as the feed tests do.
|
||||||
|
|
||||||
|
The PM world (dates are calendar days in the site time zone, America/Denver):
|
||||||
|
|
||||||
|
project L670 "Pump skids" (Acme Gas, PO-9, due today + 20), alias OLD670
|
||||||
|
unit 001 "Skid A" due yesterday -> late k_run (in progress, timer 6 h, ticket 3.2 h, target 4.5 h),
|
||||||
|
k_short (warehouse pulling, an open short with a past ETA)
|
||||||
|
unit 002 "Skid B" due today + 4 -> risk k_rev (review after a Q.C. reject), k_qc (at Q.C.)
|
||||||
|
unit 003 "Skid C" due today + 30 -> risk k_hold (warehouse hold for 5 days), k_inc (warehouse waiting)
|
||||||
|
unit 004 "Skid D" due today + 30 -> risk k_co (ready, change order CO-1 open 3 days), k_pulled
|
||||||
|
unit 005 "Skid E" due today + 30 -> done k_filed (filed, created 10 days ago), k_oldfiled (filed 120
|
||||||
|
days ago, created 140 days ago, a synthetic history)
|
||||||
|
unit 006 "Skid F" no due (project's) -> ok k_draft, k_ready, k_asg (assigned), k_complete
|
||||||
|
unit 007 "Skid G" due today + 30 -> empty
|
||||||
|
project L489 "BPX" (due today + 9), unit 052 with no packets -> empty
|
||||||
|
project L333 closed, unit 001 with no packets: not in scope
|
||||||
|
|
||||||
|
History rows inserted with old timestamps (append-only tables, so inserted, never updated):
|
||||||
|
k_draft: return_to_clerical 400 and 45 days ago (sent back)
|
||||||
|
k_oldfiled: send_to_warehouse -139 d, wh_send_back -138 d (sent back), send_to_warehouse -137 d, wh_release -130 d,
|
||||||
|
send_to_qc -126 d, qc_reject -125 d (sent back; a rejected qc_history row), send_to_qc -124 d,
|
||||||
|
qc_accept -123 d (an accepted qc_history row), file -120 d.
|
||||||
|
|
||||||
|
So: open 12, late units 1, units at risk 3, holds 1, change orders 1, shorts 1, over target 1; by period
|
||||||
|
rejects 30: 1, 90: 1, 365: 2, all: 2; filed 30: 1, 90: 1, 365: 2, all: 2;
|
||||||
|
sent back 30: 1, 90: 2, 365: 4, all: 5; lead time 90: 10.0 d, 365: 15.0 d.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fakedir import FakeDirectory
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pkthelpers import step, tiny_pdf, upload
|
||||||
|
from pshelpers import ORIGIN_HEADERS, PG_URL, app_conn, fake_visual, make_settings
|
||||||
|
from shophelpers import (
|
||||||
|
SIGN,
|
||||||
|
Shop,
|
||||||
|
draft,
|
||||||
|
issue_co,
|
||||||
|
ok,
|
||||||
|
open_shop,
|
||||||
|
pull_all,
|
||||||
|
ready_skipped,
|
||||||
|
run,
|
||||||
|
set_running_since,
|
||||||
|
start,
|
||||||
|
to_station,
|
||||||
|
to_warehouse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
SITE_TZ = "America/Denver"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the contract's texts
|
||||||
|
|
||||||
|
PERIODS = {"30": "Last 30 days", "90": "Last 90 days", "365": "Last 12 months", "all": "All time"}
|
||||||
|
|
||||||
|
PM_STEPS = (
|
||||||
|
("draft", "Ready to release (Clerical)"),
|
||||||
|
("wh_incoming", "Warehouse: waiting"),
|
||||||
|
("wh_pulling", "Warehouse: being pulled"),
|
||||||
|
("wh_hold", "Warehouse: on hold"),
|
||||||
|
("wh_pulled", "Warehouse: pulled, ready for production"),
|
||||||
|
("ready", "Ready to assign"),
|
||||||
|
("station", "At a station"),
|
||||||
|
("review", "Supervisor review"),
|
||||||
|
("qc", "At Q.C."),
|
||||||
|
("complete", "Back for data book"),
|
||||||
|
)
|
||||||
|
STEP_LABEL = dict(PM_STEPS)
|
||||||
|
|
||||||
|
HEALTH = {"ok": "On track", "risk": "At risk", "late": "Late", "done": "Complete", "empty": "No packets yet"}
|
||||||
|
|
||||||
|
KPI_KEYS = ("open", "late_units", "risk_units", "holds", "cos", "shorts", "rejects", "over", "filed", "lead")
|
||||||
|
KPI_TABS = ("aging", "projects", "projects", "quality", "quality", "quality", "quality", "hours", "projects", "aging")
|
||||||
|
KPI_ALERT = {"late_units", "risk_units", "holds", "cos", "shorts", "rejects", "over"}
|
||||||
|
|
||||||
|
|
||||||
|
def kpi_labels(period: str = "90") -> tuple[str, ...]:
|
||||||
|
lower = PERIODS[period].lower()
|
||||||
|
return (
|
||||||
|
"Open packets",
|
||||||
|
"Late units",
|
||||||
|
"Units at risk",
|
||||||
|
"Warehouse holds",
|
||||||
|
"Open change orders",
|
||||||
|
"Open shorts",
|
||||||
|
f"Q.C. rejects · {lower}",
|
||||||
|
"Packets over target hours",
|
||||||
|
f"Filed · {lower}",
|
||||||
|
"Average lead time, created → filed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
HOURS_TAIL = ["Packets", "Timer h", "Ticket h", "Target h", "Variance h", "Timer / target", "Over target"]
|
||||||
|
HEADS: dict[str, list[str]] = {
|
||||||
|
"projects": [
|
||||||
|
"Project / unit",
|
||||||
|
"Name",
|
||||||
|
"Customer",
|
||||||
|
"Due",
|
||||||
|
"Left",
|
||||||
|
"Units",
|
||||||
|
"Packets",
|
||||||
|
"Draft",
|
||||||
|
"Warehouse",
|
||||||
|
"Shop",
|
||||||
|
"Q.C.",
|
||||||
|
"Done",
|
||||||
|
"% done",
|
||||||
|
"Health",
|
||||||
|
],
|
||||||
|
"aging_steps": ["Step", "Packets", "Average", "Longest", "Oldest packet"],
|
||||||
|
"aging_open": ["Job", "Unit", "Step", "With", "In this step", "Total age", "Unit due", "Flags"],
|
||||||
|
"aging_history": ["Step", "Moves", "Average", "Longest"],
|
||||||
|
"hours_dept": ["Department", *HOURS_TAIL],
|
||||||
|
"hours_worker": ["Worker", *HOURS_TAIL],
|
||||||
|
"hours_project": ["Project", *HOURS_TAIL],
|
||||||
|
"hours_over": ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
|
||||||
|
"quality_rejects": ["Job", "Date", "Inspector", "Reason / NCR", "Station / worker"],
|
||||||
|
"quality_shorts": ["Job", "Item", "Short qty", "Reason", "PO / req", "ETA", "Age", "Packet is"],
|
||||||
|
"quality_cos": ["Job", "Change order", "Change", "Waiting on", "Open for"],
|
||||||
|
"quality_holds": ["Job", "Reason", "Note", "Put on hold by", "On hold for"],
|
||||||
|
"quality_sentback": ["Job", "When", "Action", "By", "Reason"],
|
||||||
|
"jobs": [
|
||||||
|
"Job",
|
||||||
|
"Project",
|
||||||
|
"Unit",
|
||||||
|
"Operation",
|
||||||
|
"Title",
|
||||||
|
"Department",
|
||||||
|
"Step",
|
||||||
|
"With",
|
||||||
|
"Station",
|
||||||
|
"Worker",
|
||||||
|
"Drawing",
|
||||||
|
"Rev",
|
||||||
|
"Unit due",
|
||||||
|
"Days in step",
|
||||||
|
"Timer h",
|
||||||
|
"Ticket h",
|
||||||
|
"Target h",
|
||||||
|
"Created",
|
||||||
|
"Updated",
|
||||||
|
"Change orders open",
|
||||||
|
"Shorts open",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
ATTENTION_HEAD = ["Unit", "Description", "Due", "Left", "Packets", "% done", "Health"]
|
||||||
|
|
||||||
|
|
||||||
|
def titles(period: str = "90") -> dict[str, str]:
|
||||||
|
p = PERIODS[period]
|
||||||
|
return {
|
||||||
|
"projects": "Projects and units",
|
||||||
|
"aging_steps": "Time in the current step (open packets)",
|
||||||
|
"aging_open": "Every open packet, longest waiting first",
|
||||||
|
"aging_history": f"Average time per step, moves completed ({p})",
|
||||||
|
"hours_dept": "By department",
|
||||||
|
"hours_worker": "By welder / worker",
|
||||||
|
"hours_project": "By project",
|
||||||
|
"hours_over": "Packets over target",
|
||||||
|
"quality_rejects": f"Q.C. rejects ({p})",
|
||||||
|
"quality_shorts": "Open shorts",
|
||||||
|
"quality_cos": "Open change orders",
|
||||||
|
"quality_holds": "Warehouse holds",
|
||||||
|
"quality_sentback": f"Sent back ({p})",
|
||||||
|
"jobs": "All jobs in scope",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
NOTES = {
|
||||||
|
"projects": "Tap a project to show its units. Done = accepted by Q.C. or filed. At risk = due within 7 days and"
|
||||||
|
" under 75% done, or a hold or open change order.",
|
||||||
|
"aging_steps": "How long packets have been sitting where they are right now.",
|
||||||
|
"aging_open": "Over 3 days in one step is flagged.",
|
||||||
|
"aging_history": "From the step history recorded on every move.",
|
||||||
|
"hours_over": "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the"
|
||||||
|
" clock of record.",
|
||||||
|
}
|
||||||
|
|
||||||
|
TAB_TABLES = {
|
||||||
|
"aging": ("aging_steps", "aging_open", "aging_history"),
|
||||||
|
"hours": ("hours_dept", "hours_worker", "hours_project", "hours_over"),
|
||||||
|
"quality": ("quality_rejects", "quality_shorts", "quality_cos", "quality_holds", "quality_sentback"),
|
||||||
|
}
|
||||||
|
|
||||||
|
REJECT_REASON = "Undercut on weld 1, regrind and reweld"
|
||||||
|
REJECT_NCR = "NCR-2026-0031"
|
||||||
|
HOLD_REASON = "Waiting on purchasing / PO"
|
||||||
|
HOLD_NOTE = "PO 4471"
|
||||||
|
FORMULA_TITLE = "=HYPERLINK(1)"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- one database per module
|
||||||
|
|
||||||
|
|
||||||
|
def today_site() -> date:
|
||||||
|
return datetime.now(ZoneInfo(SITE_TZ)).date()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def module_settings(tmp: Path) -> Iterator[Settings]:
|
||||||
|
"""The conftest `pg_settings` for a whole module: a fresh database with ps_migrate and ps_app, migrated to head,
|
||||||
|
dropped afterwards. Lets a module build its world once."""
|
||||||
|
import psycopg
|
||||||
|
from psycopg import sql
|
||||||
|
from psycopg.conninfo import conninfo_to_dict
|
||||||
|
|
||||||
|
from packetstation import db, migrate
|
||||||
|
|
||||||
|
assert PG_URL
|
||||||
|
info = conninfo_to_dict(PG_URL)
|
||||||
|
mig_pw, app_pw = secrets.token_urlsafe(18), secrets.token_urlsafe(18)
|
||||||
|
(tmp / "mig").write_text(mig_pw + "\n")
|
||||||
|
(tmp / "app").write_text(app_pw + "\n")
|
||||||
|
name = f"ps_test_{secrets.token_hex(4)}"
|
||||||
|
s = make_settings(
|
||||||
|
tmp,
|
||||||
|
pg_host=info.get("host", "localhost"),
|
||||||
|
pg_port=int(info.get("port", 5432)),
|
||||||
|
pg_database=name,
|
||||||
|
pg_migrate_password_file=tmp / "mig",
|
||||||
|
pg_app_password_file=tmp / "app",
|
||||||
|
pg_superuser_url=PG_URL,
|
||||||
|
# A world takes minutes to build over a slow tunnel: no idle lock or idle sign-out while it does.
|
||||||
|
session_lock_minutes=600,
|
||||||
|
session_idle_minutes=720,
|
||||||
|
)
|
||||||
|
migrate.bootstrap_roles(s)
|
||||||
|
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||||||
|
for user, pw in (("ps_migrate", mig_pw), ("ps_app", app_pw)):
|
||||||
|
su.execute(sql.SQL("ALTER ROLE {} LOGIN PASSWORD {}").format(sql.Identifier(user), sql.Literal(pw)))
|
||||||
|
with db.connect_as("migrate", s, autocommit=True) as conn:
|
||||||
|
migrate.run(conn, db.MIGRATIONS_DIR)
|
||||||
|
try:
|
||||||
|
yield s
|
||||||
|
finally:
|
||||||
|
db.close_pool()
|
||||||
|
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||||||
|
su.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(name)))
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def module_shop(tmp: Path, fake_dir: FakeDirectory | None = None) -> Iterator[Shop]:
|
||||||
|
"""The app on a module database with the fake directory and DEV VISUAL settings; every test user signed in."""
|
||||||
|
from packetstation import guard
|
||||||
|
|
||||||
|
with pytest.MonkeyPatch.context() as mp:
|
||||||
|
mp.setattr(guard, "load_visual_settings", lambda: fake_visual(False))
|
||||||
|
with module_settings(tmp) as s:
|
||||||
|
gen = open_shop(s, fake_dir or FakeDirectory())
|
||||||
|
shop = next(gen)
|
||||||
|
try:
|
||||||
|
yield shop
|
||||||
|
finally:
|
||||||
|
gen.close()
|
||||||
|
|
||||||
|
|
||||||
|
def anon(shop: Shop) -> TestClient:
|
||||||
|
"""A client with no session."""
|
||||||
|
return TestClient(shop.main.app, headers=ORIGIN_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_ok(c: TestClient, url: str, **params: Any) -> dict[str, Any]:
|
||||||
|
r = c.get(url, params={k: v for k, v in params.items() if v is not None})
|
||||||
|
assert r.status_code == 200, (url, params, r.text)
|
||||||
|
out: dict[str, Any] = r.json()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- SQL setup
|
||||||
|
|
||||||
|
|
||||||
|
def make_project(
|
||||||
|
settings: Settings,
|
||||||
|
number: str,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
customer: str | None = None,
|
||||||
|
po: str | None = None,
|
||||||
|
due: date | None = None,
|
||||||
|
status: str = "open",
|
||||||
|
) -> int:
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"INSERT INTO projects (number, name, customer, po, due, status)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s, %s::project_status) RETURNING id",
|
||||||
|
(number, name, customer, po, due, status),
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def make_unit(settings: Settings, project_id: int, lot: str, name: str | None = None, due: date | None = None) -> int:
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"INSERT INTO units (project_id, lot, name, due) VALUES (%s, %s, %s, %s) RETURNING id",
|
||||||
|
(project_id, lot, name, due),
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def add_alias(settings: Settings, project_id: int, alias: str) -> None:
|
||||||
|
run(
|
||||||
|
settings,
|
||||||
|
"INSERT INTO project_aliases (alias, project_id, kind) VALUES (%s, %s, 'import')",
|
||||||
|
(alias.upper(), project_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def transition_at(
|
||||||
|
settings: Settings,
|
||||||
|
packet_id: int,
|
||||||
|
days_ago: float,
|
||||||
|
action: str,
|
||||||
|
from_status: str | None,
|
||||||
|
to_status: str | None,
|
||||||
|
*,
|
||||||
|
by: str = "clerk.clara",
|
||||||
|
reason: str | None = None,
|
||||||
|
dept: str = "03",
|
||||||
|
) -> None:
|
||||||
|
"""One packet_transitions row with an old timestamp (the table is append-only: insert, never update)."""
|
||||||
|
run(
|
||||||
|
settings,
|
||||||
|
"INSERT INTO packet_transitions (packet_id, at, by, action, from_status, to_status, from_dept, to_dept,"
|
||||||
|
" reason) VALUES (%s, now() - make_interval(secs => %s), (SELECT id FROM users WHERE sam_account = %s),"
|
||||||
|
" %s, %s::packet_status, %s::packet_status, %s, %s, %s)",
|
||||||
|
(packet_id, days_ago * 86400, by, action, from_status, to_status, dept, dept, reason),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def qc_history_at(settings: Settings, packet_id: int, days_ago: float, result: str, note: str | None = None) -> None:
|
||||||
|
run(
|
||||||
|
settings,
|
||||||
|
"INSERT INTO qc_history (packet_id, at, result, inspector, note, dept_code, snapshot)"
|
||||||
|
" VALUES (%s, now() - make_interval(secs => %s), %s, 'Quinn QC', %s, '03', '{}')",
|
||||||
|
(packet_id, days_ago * 86400, result, note),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def labor_rows(settings: Settings, packet_id: int, rows: list[tuple[str, int, str]]) -> None:
|
||||||
|
"""Make a packet a VISUAL packet and give its work orders VISUAL ticket hours: rows of (wo_base, seq, hours).
|
||||||
|
Each work order gets the seq of its row; visual_labor gets one row per entry, as the feed writes them."""
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
conn.execute("UPDATE packets SET source_kind = 'visual' WHERE id = %s", (packet_id,))
|
||||||
|
for base, seq, hours in rows:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE packet_work_orders SET seq = %s WHERE packet_id = %s AND wo_base = %s", (seq, packet_id, base)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO visual_labor (wo_base, lot, split, sub, seq, hours, tickets, last_ticket)"
|
||||||
|
" SELECT wo_base, lot, split, sub, seq, %s, 2, current_date FROM packet_work_orders"
|
||||||
|
" WHERE packet_id = %s AND wo_base = %s"
|
||||||
|
" ON CONFLICT (wo_base, lot, split, sub, seq) DO UPDATE SET hours = EXCLUDED.hours",
|
||||||
|
(hours, packet_id, base),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def quick_draft(
|
||||||
|
shop: Shop, unit_id: int, title: str, *, n: int, send: str | None = None, **over: Any
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""A hand draft by clerk.clara with neutral values (drawing D-61nn, work order Wnn XY, no BOM) unless
|
||||||
|
overridden. `send` "warehouse" sends it to the warehouse; a department code releases it there (ready). Either
|
||||||
|
attaches a one-page drawing and answers the heat map first. A bare draft costs one request."""
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"dept_code": "03",
|
||||||
|
"op": "WELD-PART",
|
||||||
|
"title": title,
|
||||||
|
"customer": "Plains Midstream",
|
||||||
|
"drawing": f"D-61{n:02d}",
|
||||||
|
"rev": "A",
|
||||||
|
"wps": "WPS-11",
|
||||||
|
"work_orders": [
|
||||||
|
{"wo_base": f"W{n:02d}XY", "lot": "001", "split": "0", "sub": "0", "qty": 1, "target_hrs": 1},
|
||||||
|
],
|
||||||
|
"bom": [],
|
||||||
|
}
|
||||||
|
body.update(over)
|
||||||
|
c = shop("clerk.clara")
|
||||||
|
r = c.post("/api/packets", json=body)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
pkt: dict[str, Any] = r.json()
|
||||||
|
if send:
|
||||||
|
assert c.patch(f"/api/packets/{pkt['id']}", json={"set": {"heat_map": True}, "base": {}}).status_code == 200
|
||||||
|
a = upload(c, tiny_pdf(1))
|
||||||
|
assert a.status_code == 201, a.text
|
||||||
|
pkt = ok(
|
||||||
|
c.post(
|
||||||
|
f"/api/packets/{pkt['id']}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if send == "warehouse":
|
||||||
|
pkt = ok(step(c, pkt, "send_to_warehouse"))
|
||||||
|
assert pkt["status"] == "warehouse", pkt["status"]
|
||||||
|
else:
|
||||||
|
pkt = ok(step(c, pkt, "release_to_shop", dept=send))
|
||||||
|
assert pkt["status"] == "ready", pkt["status"]
|
||||||
|
return pkt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the PM world
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PmWorld:
|
||||||
|
shop: Shop
|
||||||
|
today: date
|
||||||
|
projects: dict[str, int] = field(default_factory=dict) # "L670", "L489", "L333"
|
||||||
|
units: dict[int, int] = field(default_factory=dict) # 1..7 -> L670 units; 52 -> L489 052
|
||||||
|
k: dict[str, int] = field(default_factory=dict) # packet ids by key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def settings(self) -> Settings:
|
||||||
|
return self.shop.settings
|
||||||
|
|
||||||
|
def unit_number(self, n: int) -> str:
|
||||||
|
return f"L670{n:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
OPEN_KEYS = (
|
||||||
|
"k_run",
|
||||||
|
"k_short",
|
||||||
|
"k_rev",
|
||||||
|
"k_qc",
|
||||||
|
"k_hold",
|
||||||
|
"k_inc",
|
||||||
|
"k_co",
|
||||||
|
"k_pulled",
|
||||||
|
"k_draft",
|
||||||
|
"k_ready",
|
||||||
|
"k_asg",
|
||||||
|
"k_complete",
|
||||||
|
)
|
||||||
|
FILED_KEYS = ("k_filed", "k_oldfiled")
|
||||||
|
|
||||||
|
# The PM step of each open packet, and the expected pipeline counts.
|
||||||
|
STEP_OF = {
|
||||||
|
"k_draft": "draft",
|
||||||
|
"k_inc": "wh_incoming",
|
||||||
|
"k_short": "wh_pulling",
|
||||||
|
"k_hold": "wh_hold",
|
||||||
|
"k_pulled": "wh_pulled",
|
||||||
|
"k_ready": "ready",
|
||||||
|
"k_co": "ready",
|
||||||
|
"k_asg": "station",
|
||||||
|
"k_run": "station",
|
||||||
|
"k_rev": "review",
|
||||||
|
"k_qc": "qc",
|
||||||
|
"k_complete": "complete",
|
||||||
|
}
|
||||||
|
PIPELINE_COUNTS = {key: sum(1 for v in STEP_OF.values() if v == key) for key, _ in PM_STEPS}
|
||||||
|
|
||||||
|
UNIT_HEALTH = {1: "late", 2: "risk", 3: "risk", 4: "risk", 5: "done", 6: "ok", 7: "empty"}
|
||||||
|
|
||||||
|
# By period: Q.C. rejects, filed, sent back, lead time.
|
||||||
|
BY_PERIOD = {
|
||||||
|
"30": {"rejects": 1, "filed": 1, "sentback": 1},
|
||||||
|
"90": {"rejects": 1, "filed": 1, "sentback": 2, "lead": "10.0 d"},
|
||||||
|
"365": {"rejects": 2, "filed": 2, "sentback": 4, "lead": "15.0 d"},
|
||||||
|
"all": {"rejects": 2, "filed": 2, "sentback": 5},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pm_world(shop: Shop) -> PmWorld:
|
||||||
|
"""Build the PM world described at the top of this file. Takes a few minutes over a slow tunnel."""
|
||||||
|
from closehelpers import at_complete, filed
|
||||||
|
from qchelpers import at_qc, reject
|
||||||
|
|
||||||
|
s = shop.settings
|
||||||
|
today = today_site()
|
||||||
|
w = PmWorld(shop, today)
|
||||||
|
pa = make_project(s, "L670", "Pump skids", customer="Acme Gas", po="PO-9", due=today + timedelta(days=20))
|
||||||
|
add_alias(s, pa, "OLD670")
|
||||||
|
w.projects["L670"] = pa
|
||||||
|
dues = {
|
||||||
|
1: today - timedelta(days=1),
|
||||||
|
2: today + timedelta(days=4),
|
||||||
|
3: today + timedelta(days=30),
|
||||||
|
4: today + timedelta(days=30),
|
||||||
|
5: today + timedelta(days=30),
|
||||||
|
6: None,
|
||||||
|
7: today + timedelta(days=30),
|
||||||
|
}
|
||||||
|
for n, due in dues.items():
|
||||||
|
w.units[n] = make_unit(s, pa, f"{n:03d}", f"Skid {'ABCDEFG'[n - 1]}", due)
|
||||||
|
pb = make_project(s, "L489", "BPX", customer="BPX", due=today + timedelta(days=9))
|
||||||
|
w.projects["L489"] = pb
|
||||||
|
w.units[52] = make_unit(s, pb, "052", "48in separator")
|
||||||
|
pc = make_project(s, "L333", "Old closed job", status="closed")
|
||||||
|
w.projects["L333"] = pc
|
||||||
|
make_unit(s, pc, "001", "Closed unit")
|
||||||
|
u = w.units
|
||||||
|
wade = shop("wh.wade")
|
||||||
|
|
||||||
|
# unit 001, late: a running packet over target with ticket hours, a short with a past ETA
|
||||||
|
run_pkt = start(shop, to_station(shop, ready_skipped(shop, u[1], title="Running spool")))
|
||||||
|
w.k["k_run"] = run_pkt["id"]
|
||||||
|
run(
|
||||||
|
s,
|
||||||
|
"UPDATE packet_stages SET clock_elapsed_ms = %s WHERE packet_id = %s AND closed_at IS NULL",
|
||||||
|
(4 * 3600 * 1000, run_pkt["id"]),
|
||||||
|
)
|
||||||
|
set_running_since(s, run_pkt["id"], 2 * 3600)
|
||||||
|
labor_rows(s, run_pkt["id"], [("L670001", 10, "1.5"), ("L670002", 20, "1.7")])
|
||||||
|
|
||||||
|
short = to_warehouse(shop, draft(shop, u[1], title="Short spool"))
|
||||||
|
yesterday = (today - timedelta(days=1)).isoformat()
|
||||||
|
short = ok(
|
||||||
|
wade.post(
|
||||||
|
f"/api/packets/{short['id']}/bom/1/short",
|
||||||
|
json={"qty": 1, "reason": "On order", "ref": "PO-77", "eta": yesterday, "expect_open": False},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
w.k["k_short"] = short["id"]
|
||||||
|
|
||||||
|
# unit 002, at risk by the due rule: a packet back in review after a Q.C. reject, a packet at Q.C.
|
||||||
|
rev = at_qc(shop, u[2], title="Rejected spool")
|
||||||
|
rev = ok(reject(shop, rev, REJECT_REASON, ncr=REJECT_NCR))
|
||||||
|
assert rev["status"] == "review", rev["status"]
|
||||||
|
w.k["k_rev"] = rev["id"]
|
||||||
|
w.k["k_qc"] = at_qc(shop, u[2], title="Inspected spool")["id"]
|
||||||
|
|
||||||
|
# unit 003, at risk by a hold: a warehouse hold five days old, a packet waiting
|
||||||
|
hold = to_warehouse(shop, draft(shop, u[3], title="Held spool"))
|
||||||
|
ok(
|
||||||
|
wade.post(
|
||||||
|
f"/api/packets/{hold['id']}/wh",
|
||||||
|
json={"stage": "hold", "reason": HOLD_REASON, "note": HOLD_NOTE, "expect_stage": "incoming"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
run(
|
||||||
|
s,
|
||||||
|
"UPDATE packet_wh SET at = now() - interval '5 days' WHERE packet_id = %s AND closed_at IS NULL",
|
||||||
|
(hold["id"],),
|
||||||
|
)
|
||||||
|
w.k["k_hold"] = hold["id"]
|
||||||
|
w.k["k_inc"] = to_warehouse(shop, draft(shop, u[3], title="Waiting spool"))["id"]
|
||||||
|
|
||||||
|
# unit 004, at risk by a change order: CO-1 open three days, a packet pulled
|
||||||
|
co = issue_co(shop, ready_skipped(shop, u[4], title="Changed spool"), ["03"])
|
||||||
|
run(s, "UPDATE change_orders SET issued_at = now() - interval '3 days' WHERE packet_id = %s", (co["id"],))
|
||||||
|
w.k["k_co"] = co["id"]
|
||||||
|
pulled = pull_all(shop, to_warehouse(shop, draft(shop, u[4], title="Pulled spool")))
|
||||||
|
ok(wade.post(f"/api/packets/{pulled['id']}/wh", json={"stage": "pulled", "expect_stage": "pulling"}))
|
||||||
|
w.k["k_pulled"] = pulled["id"]
|
||||||
|
|
||||||
|
# unit 005, done: one filed now (created ten days ago), one filed 120 days ago with a synthetic history
|
||||||
|
fp = filed(shop, u[5])
|
||||||
|
run(s, "UPDATE packets SET created_at = now() - interval '10 days' WHERE id = %s", (fp["id"],))
|
||||||
|
w.k["k_filed"] = fp["id"]
|
||||||
|
old = draft(shop, u[5], title="Old filed spool")
|
||||||
|
oid = old["id"]
|
||||||
|
run(
|
||||||
|
s,
|
||||||
|
"UPDATE packets SET status = 'filed', status_at = now() - interval '120 days',"
|
||||||
|
" created_at = now() - interval '140 days' WHERE id = %s",
|
||||||
|
(oid,),
|
||||||
|
)
|
||||||
|
for days, action, frm, to, reason in (
|
||||||
|
(139, "send_to_warehouse", "draft", "warehouse", None),
|
||||||
|
(138, "wh_send_back", "warehouse", "draft", "Wrong BOM"),
|
||||||
|
(137, "send_to_warehouse", "draft", "warehouse", None),
|
||||||
|
(130, "wh_release", "warehouse", "ready", None),
|
||||||
|
(126, "send_to_qc", "review", "qc", None),
|
||||||
|
(125, "qc_reject", "qc", "review", "Porosity at W2"),
|
||||||
|
(124, "send_to_qc", "review", "qc", None),
|
||||||
|
(123, "qc_accept", "qc", "complete", None),
|
||||||
|
(120, "file", "complete", "filed", None),
|
||||||
|
):
|
||||||
|
transition_at(s, oid, days, action, frm, to, reason=reason)
|
||||||
|
qc_history_at(s, oid, 125, "rejected", "Porosity at W2")
|
||||||
|
qc_history_at(s, oid, 123, "accepted")
|
||||||
|
w.k["k_oldfiled"] = oid
|
||||||
|
|
||||||
|
# unit 006, on track: a draft (sent back twice long ago), ready, assigned, complete
|
||||||
|
dr = draft(shop, u[6], title="Draft spool")
|
||||||
|
w.k["k_draft"] = dr["id"]
|
||||||
|
transition_at(s, dr["id"], 400, "return_to_clerical", "ready", "draft", by="sup.wes", reason="Wrong department")
|
||||||
|
transition_at(s, dr["id"], 45, "return_to_clerical", "ready", "draft", by="sup.wes", reason="Missing drawing")
|
||||||
|
w.k["k_ready"] = ready_skipped(shop, u[6], title="Ready spool")["id"]
|
||||||
|
w.k["k_asg"] = to_station(shop, ready_skipped(shop, u[6], title="Assigned spool"), station="Stall 2")["id"]
|
||||||
|
w.k["k_complete"] = at_complete(shop, u[6])["id"]
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def by_key(rows: list[dict[str, Any]], key: str = "key") -> dict[str, dict[str, Any]]:
|
||||||
|
return {str(r[key]): r for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def table(answer: dict[str, Any], key: str) -> dict[str, Any]:
|
||||||
|
found = [t for t in answer["tables"] if t["key"] == key]
|
||||||
|
assert found, (key, [t["key"] for t in answer["tables"]])
|
||||||
|
out: dict[str, Any] = found[0]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def rows_by_pid(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||||
|
return {int(r["pid"]): r for r in t["rows"] if r.get("pid") is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def unit_rows(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||||
|
return {int(r["unit_id"]): r for r in t["rows"] if r.get("lvl") == 1 and r.get("unit_id") is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def project_rows(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||||
|
return {int(r["project_id"]): r for r in t["rows"] if r.get("lvl") == 0 and r.get("project_id") is not None}
|
||||||
@@ -14,6 +14,11 @@ def _sub(hub: events.EventHub, name: str, screen: str | None = None) -> events.S
|
|||||||
return hub.subscribe(session_id=uuid.uuid4(), user_id=uuid.uuid4(), display_name=name, screen=screen)
|
return hub.subscribe(session_id=uuid.uuid4(), user_id=uuid.uuid4(), display_name=name, screen=screen)
|
||||||
|
|
||||||
|
|
||||||
|
def _bare(data: dict) -> dict:
|
||||||
|
"""A presence payload without the `since` times (they are the subscribe instants)."""
|
||||||
|
return {"users": [{k: v for k, v in u.items() if k != "since"} for u in data["users"]]}
|
||||||
|
|
||||||
|
|
||||||
async def _drain(q: asyncio.Queue[events.Item]) -> list[events.Item]:
|
async def _drain(q: asyncio.Queue[events.Item]) -> list[events.Item]:
|
||||||
await asyncio.sleep(0) # let call_soon_threadsafe callbacks run
|
await asyncio.sleep(0) # let call_soon_threadsafe callbacks run
|
||||||
out = []
|
out = []
|
||||||
@@ -29,12 +34,13 @@ def test_fan_out_presence_and_session_targeting() -> None:
|
|||||||
b = _sub(hub, "Bo", "stall")
|
b = _sub(hub, "Bo", "stall")
|
||||||
got_a = await _drain(a.queue)
|
got_a = await _drain(a.queue)
|
||||||
assert [n for n, _ in got_a] == ["presence", "presence"]
|
assert [n for n, _ in got_a] == ["presence", "presence"]
|
||||||
assert got_a[-1][1] == {
|
assert _bare(got_a[-1][1]) == {
|
||||||
"users": [
|
"users": [
|
||||||
{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None},
|
{"user_id": a.user_id, "display_name": "Ann", "role": "", "screen": "admin", "packet_id": None},
|
||||||
{"user_id": b.user_id, "display_name": "Bo", "screen": "stall", "packet_id": None},
|
{"user_id": b.user_id, "display_name": "Bo", "role": "", "screen": "stall", "packet_id": None},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
assert all(u["since"] for u in got_a[-1][1]["users"])
|
||||||
await _drain(b.queue)
|
await _drain(b.queue)
|
||||||
|
|
||||||
hub._deliver(json.dumps({"event": "changed", "collection": "packets", "id": 7}))
|
hub._deliver(json.dumps({"event": "changed", "collection": "packets", "id": 7}))
|
||||||
@@ -46,14 +52,18 @@ def test_fan_out_presence_and_session_targeting() -> None:
|
|||||||
("changed", {"collection": "projects"}),
|
("changed", {"collection": "projects"}),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# A revoked session drops out of presence at once; the event itself goes to that session only.
|
||||||
hub._deliver(json.dumps({"event": "session", "session_id": b.session_id, "action": "revoked"}))
|
hub._deliver(json.dumps({"event": "session", "session_id": b.session_id, "action": "revoked"}))
|
||||||
assert await _drain(a.queue) == []
|
users = [{"user_id": a.user_id, "display_name": "Ann", "role": "", "screen": "admin", "packet_id": None}]
|
||||||
assert await _drain(b.queue) == [("session", {"session_id": b.session_id, "action": "revoked"})]
|
got = await _drain(a.queue)
|
||||||
|
assert [(n, _bare(d)) for n, d in got] == [("presence", {"users": users})]
|
||||||
|
got_b = await _drain(b.queue)
|
||||||
|
assert got_b[0] == ("session", {"session_id": b.session_id, "action": "revoked"})
|
||||||
|
assert [n for n, _ in got_b] == ["session", "presence"]
|
||||||
|
|
||||||
hub.unsubscribe(b)
|
hub.unsubscribe(b)
|
||||||
got = await _drain(a.queue)
|
got = await _drain(a.queue)
|
||||||
users = [{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None}]
|
assert [(n, _bare(d)) for n, d in got] == [("presence", {"users": users})]
|
||||||
assert got == [("presence", {"users": users})]
|
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
"""Phase 8: the find bar filters applied by the server: status words, department, station, worker, the quick flags
|
||||||
|
(Mine per screen), the flag kinds, the sorts and the facets, on `GET /api/packets` and on the boards.
|
||||||
|
|
||||||
|
The world is built once per module. Most statuses past `ready` are set by SQL: the filters read the packet row only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pmhelpers import get_ok, make_project, make_unit, module_shop, quick_draft, today_site
|
||||||
|
from pshelpers import needs_pg
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
from shophelpers import Shop, issue_co, ok, run, step
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
DRAFTS = ("d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9")
|
||||||
|
STATUS_ORDER = ("draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete", "filed")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FilterWorld:
|
||||||
|
shop: Shop
|
||||||
|
k: dict[str, int] = field(default_factory=dict)
|
||||||
|
projects: dict[str, int] = field(default_factory=dict)
|
||||||
|
units: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def ids(self, *keys: str) -> set[int]:
|
||||||
|
return {self.k[x] for x in keys}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def every(self) -> set[int]:
|
||||||
|
return set(self.k.values())
|
||||||
|
|
||||||
|
def but(self, *keys: str) -> set[int]:
|
||||||
|
return self.every - self.ids(*keys)
|
||||||
|
|
||||||
|
|
||||||
|
def title(key: str) -> str:
|
||||||
|
return f"ZX{key} spool"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[FilterWorld]:
|
||||||
|
with module_shop(tmp_path_factory.mktemp("filters")) as shop:
|
||||||
|
s = shop.settings
|
||||||
|
today = today_site()
|
||||||
|
w = FilterWorld(shop)
|
||||||
|
fa = make_project(s, "L700", "Filter project", customer="Acme", due=today + timedelta(days=60))
|
||||||
|
fb = make_project(s, "L60", "Second project")
|
||||||
|
w.projects = {"fa": fa, "fb": fb}
|
||||||
|
w.units = {
|
||||||
|
"f1": make_unit(s, fa, "001", due=today - timedelta(days=1)), # overdue
|
||||||
|
"f2": make_unit(s, fa, "002", due=today + timedelta(days=3)), # due within 7 days
|
||||||
|
"f3": make_unit(s, fa, "003", due=today + timedelta(days=30)),
|
||||||
|
"g1": make_unit(s, fb, "001"), # no due date anywhere
|
||||||
|
}
|
||||||
|
|
||||||
|
def mk(key: str, unit: str, send: str | None = None, **over: Any) -> dict[str, Any]:
|
||||||
|
pkt = quick_draft(shop, w.units[unit], title(key), n=len(w.k) + 1, send=send, **over)
|
||||||
|
w.k[key] = int(pkt["id"])
|
||||||
|
return pkt
|
||||||
|
|
||||||
|
# drafts: one hand draft, VISUAL drafts with each flag kind, an unconfirmed rev and a clean one. The rev
|
||||||
|
# confirmation lives in visual->>'rev_confirmed' (the drawing job writes it); revOk is the old name.
|
||||||
|
mk("d1", "f3")
|
||||||
|
visual = {
|
||||||
|
"d2": (["rev_mismatch"], "rev_mismatch", {}),
|
||||||
|
"d3": (["not_found"], "not_found", {}),
|
||||||
|
"d4": ([], "attached", {}),
|
||||||
|
"d5": (["lookup_failed"], "lookup_failed", {}),
|
||||||
|
"d6": (["drawing_needed"], "needed", {}),
|
||||||
|
"d7": (["not_released"], "not_released", {}),
|
||||||
|
"d8": (["hand_drawing"], "hand", {"revOk": True, "rev_confirmed": True}),
|
||||||
|
"d9": ([], "attached", {"revOk": True, "rev_confirmed": True}),
|
||||||
|
}
|
||||||
|
for key, (flags, state, vis) in visual.items():
|
||||||
|
mk(key, "f3")
|
||||||
|
run(
|
||||||
|
s,
|
||||||
|
"UPDATE packets SET source_kind = 'visual', flags = %s, drawing_state = %s::drawing_state,"
|
||||||
|
" visual = %s WHERE id = %s",
|
||||||
|
(flags, state, Jsonb(vis), w.k[key]),
|
||||||
|
)
|
||||||
|
# the warehouse: one waiting (overdue unit), one with an open short (unit due this week)
|
||||||
|
mk("w1", "f1", send="warehouse")
|
||||||
|
w2 = mk("w2", "f2", send="warehouse", bom=[{"item": 1, "qty": 2, "part_id": "PIPE-4", "description": "Pipe"}])
|
||||||
|
ok(
|
||||||
|
shop("wh.wade").post(
|
||||||
|
f"/api/packets/{w2['id']}/bom/1/short", json={"qty": 1, "reason": "On order", "expect_open": False}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# the shop: ready with an open change order, two at stations, one ready in 06 on the second project
|
||||||
|
issue_co(shop, mk("r1", "f3", send="03"), ["03"])
|
||||||
|
a1 = mk("a1", "f1", send="03")
|
||||||
|
ok(step(shop("sup.wes"), a1, "assign", station_id=shop.stall("03", "Stall 1"), welder_id=shop.uid("weld.andy")))
|
||||||
|
a2 = mk("a2", "f3", send="03")
|
||||||
|
a2 = ok(
|
||||||
|
step(shop("sup.wes"), a2, "assign", station_id=shop.stall("03", "Stall 2"), welder_id=shop.uid("dup.dave"))
|
||||||
|
)
|
||||||
|
ok(step(shop("dup.dave"), a2, "start"))
|
||||||
|
mk("s6", "g1", send="06")
|
||||||
|
# later statuses by SQL
|
||||||
|
for key, unit, status, routed in (
|
||||||
|
("rv1", "f2", "review", None),
|
||||||
|
("q1", "f3", "qc", None),
|
||||||
|
("c1", "f3", "complete", "databook"),
|
||||||
|
("c2", "f3", "complete", "shipping"),
|
||||||
|
("fl1", "f3", "filed", "databook"),
|
||||||
|
):
|
||||||
|
mk(key, unit, send="03")
|
||||||
|
run(
|
||||||
|
s,
|
||||||
|
"UPDATE packets SET status = %s::packet_status, routed_to = %s::route_kind, status_at = now()"
|
||||||
|
" WHERE id = %s",
|
||||||
|
(status, routed, w.k[key]),
|
||||||
|
)
|
||||||
|
yield w
|
||||||
|
|
||||||
|
|
||||||
|
def cards(w: FilterWorld, sam: str = "admin.ann", **params: Any) -> list[dict[str, Any]]:
|
||||||
|
out = get_ok(w.shop(sam), "/api/packets", **params)
|
||||||
|
got: list[dict[str, Any]] = out["packets"]
|
||||||
|
return got
|
||||||
|
|
||||||
|
|
||||||
|
def ids(w: FilterWorld, sam: str = "admin.ann", **params: Any) -> set[int]:
|
||||||
|
got = [int(c["id"]) for c in cards(w, sam, **params)]
|
||||||
|
assert len(got) == len(set(got)), got
|
||||||
|
return set(got)
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ status, department, station, worker
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_WORDS = {
|
||||||
|
"draft": DRAFTS,
|
||||||
|
"warehouse": ("w1", "w2"),
|
||||||
|
"ready": ("r1", "s6"),
|
||||||
|
"shop": ("r1", "s6", "a1", "a2", "rv1", "q1"),
|
||||||
|
"station": ("a1", "a2"),
|
||||||
|
"review": ("rv1",),
|
||||||
|
"qc": ("q1",),
|
||||||
|
"complete": ("c1", "c2"),
|
||||||
|
"filed": ("fl1",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("word", [*STATUS_WORDS, "open"])
|
||||||
|
def test_status_words(world: FilterWorld, word: str) -> None:
|
||||||
|
want = world.but("fl1") if word == "open" else world.ids(*STATUS_WORDS[word])
|
||||||
|
assert ids(world, status=word) == want
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_status_list_still_works(world: FilterWorld) -> None:
|
||||||
|
assert ids(world, status="ready,review") == world.ids("r1", "s6", "rv1")
|
||||||
|
assert ids(world, status="assigned") == world.ids("a1")
|
||||||
|
assert ids(world, status="in_progress") == world.ids("a2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dept_station_worker(world: FilterWorld) -> None:
|
||||||
|
shop = world.shop
|
||||||
|
assert ids(world, dept="06") == world.ids("s6")
|
||||||
|
assert ids(world, dept="03") == world.but("s6")
|
||||||
|
assert ids(world, station_id=shop.stall("03", "Stall 1")) == world.ids("a1")
|
||||||
|
assert ids(world, station_id=shop.stall("03", "Stall 2")) == world.ids("a2")
|
||||||
|
assert ids(world, welder_id=shop.uid("weld.andy")) == world.ids("a1")
|
||||||
|
assert ids(world, welder_id=shop.uid("dup.dave")) == world.ids("a2")
|
||||||
|
assert ids(world, status="station", welder_id=shop.uid("dup.dave")) == world.ids("a2")
|
||||||
|
assert ids(world, status="draft", welder_id=shop.uid("dup.dave")) == set()
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ quick flags
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("flags", "keys"),
|
||||||
|
[
|
||||||
|
("co", ("r1",)),
|
||||||
|
("short", ("w2",)),
|
||||||
|
("overdue", ("w1", "a1")),
|
||||||
|
("week", ("w1", "a1", "w2", "rv1")),
|
||||||
|
("short,week", ("w2",)),
|
||||||
|
("co,short", ()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_quick_flags(world: FilterWorld, flags: str, keys: tuple[str, ...]) -> None:
|
||||||
|
assert ids(world, flags=flags) == world.ids(*keys)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mine_per_screen(world: FilterWorld) -> None:
|
||||||
|
shop = world.shop
|
||||||
|
clerical = world.ids(*DRAFTS, "c1")
|
||||||
|
assert ids(world, "clerk.clara", flags="mine") == clerical # the first screen of the roles
|
||||||
|
assert ids(world, "clerk.clara", flags="mine", mine_screen="clerical") == clerical
|
||||||
|
assert ids(world, "wh.wade", flags="mine", mine_screen="warehouse") == world.ids("w1", "w2")
|
||||||
|
assert ids(world, "sup.wes", flags="mine", mine_screen="supervisor") == world.but("s6")
|
||||||
|
assert ids(world, "multi.mia", flags="mine", mine_screen="supervisor") == world.ids("s6")
|
||||||
|
assert ids(world, "multi.mia", flags="mine") == clerical # Clerical comes first in the screen order
|
||||||
|
stall1 = shop.stall("03", "Stall 1")
|
||||||
|
assert ids(world, "weld.andy", flags="mine", mine_screen="stall", mine_station_id=stall1) == world.ids("a1")
|
||||||
|
assert ids(world, "weld.andy", flags="mine", mine_screen="stall") == set() # no station picked
|
||||||
|
assert ids(world, "qc.quinn", flags="mine", mine_screen="qc") == world.ids("q1")
|
||||||
|
assert ids(world, "pm.paula", flags="mine") == world.every
|
||||||
|
assert ids(world, "admin.ann", flags="mine", mine_screen="admin") == world.every
|
||||||
|
# mine with another flag narrows further
|
||||||
|
assert ids(world, "wh.wade", flags="mine,short", mine_screen="warehouse") == world.ids("w2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mine_never_widens(world: FilterWorld) -> None:
|
||||||
|
"""A screen the caller does not have gives nothing more than the caller's own screens would."""
|
||||||
|
got = ids(world, "clerk.clara", flags="mine", mine_screen="qc")
|
||||||
|
assert got in (set(), world.ids("q1"), world.ids(*DRAFTS, "c1"))
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ flag kinds
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("flag", "keys"),
|
||||||
|
[
|
||||||
|
("any", ("d1", "d2", "d3", "d5", "d6", "d7", "d8")),
|
||||||
|
("1", ("d1", "d2", "d3", "d5", "d6", "d7", "d8")),
|
||||||
|
("rev", ("d2",)),
|
||||||
|
("rev_mismatch", ("d2",)),
|
||||||
|
("unconf", ("d4",)),
|
||||||
|
("hand", ("d1", "d8")),
|
||||||
|
("hand_upload", ("d8",)),
|
||||||
|
("none", ("d7",)),
|
||||||
|
("vault", ("d3",)),
|
||||||
|
("read", ("d5",)),
|
||||||
|
("wait", ("d6",)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_flag_kinds(world: FilterWorld, flag: str, keys: tuple[str, ...]) -> None:
|
||||||
|
assert ids(world, flag=flag) == world.ids(*keys)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("flag", ["no", "0"])
|
||||||
|
def test_not_flagged(world: FilterWorld, flag: str) -> None:
|
||||||
|
assert ids(world, flag=flag) == world.but("d1", "d2", "d3", "d5", "d6", "d7", "d8")
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ sorts
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_updated_is_the_default(world: FilterWorld) -> None:
|
||||||
|
for got in (cards(world), cards(world, sort="updated")):
|
||||||
|
upd = [c["updated_at"] for c in got]
|
||||||
|
assert upd == sorted(upd, reverse=True)
|
||||||
|
assert [c["id"] for c in cards(world)] == [c["id"] for c in cards(world, sort="updated")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_project(world: FilterWorld) -> None:
|
||||||
|
got = cards(world, sort="project")
|
||||||
|
keys = [(c["project"]["number"].encode(), c["unit"]["number"].encode(), c["op"] or "") for c in got]
|
||||||
|
assert keys == sorted(keys) # C collation: byte order
|
||||||
|
assert got[0]["project"]["number"] == "L60" # "L60" sorts before "L700"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_due(world: FilterWorld) -> None:
|
||||||
|
got = cards(world, sort="due")
|
||||||
|
due = [c["due_effective"] for c in got]
|
||||||
|
assert due == sorted(due, key=lambda d: (d is None, d or ""))
|
||||||
|
assert got[-1]["id"] == world.k["s6"] # no due date: last
|
||||||
|
assert {got[0]["id"], got[1]["id"]} == world.ids("w1", "a1") # the overdue unit first
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_status(world: FilterWorld) -> None:
|
||||||
|
order = [STATUS_ORDER.index(c["status"]) for c in cards(world, sort="status")]
|
||||||
|
assert order == sorted(order)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_dept(world: FilterWorld) -> None:
|
||||||
|
got = cards(world, sort="dept")
|
||||||
|
depts = [c["dept"]["code"] for c in got]
|
||||||
|
assert depts == sorted(depts)
|
||||||
|
in03 = [c["updated_at"] for c in got if c["dept"]["code"] == "03"]
|
||||||
|
assert in03 == sorted(in03, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_unknown_is_refused_or_default(world: FilterWorld) -> None:
|
||||||
|
r = world.shop("admin.ann").get("/api/packets", params={"sort": "drop table"})
|
||||||
|
assert r.status_code in (200, 400, 422), r.text
|
||||||
|
if r.status_code == 200:
|
||||||
|
assert [c["id"] for c in r.json()["packets"]] == [c["id"] for c in cards(world)]
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ facets
|
||||||
|
|
||||||
|
|
||||||
|
def test_facets_follow_the_project_and_unit_only(world: FilterWorld) -> None:
|
||||||
|
shop = world.shop
|
||||||
|
plain = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"])
|
||||||
|
assert "facets" not in plain
|
||||||
|
out = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"], facets=1)
|
||||||
|
f = out["facets"]
|
||||||
|
assert [(x["name"], x["dept_code"]) for x in f["stations"]] == [("Stall 1", "03"), ("Stall 2", "03")]
|
||||||
|
assert {x["id"] for x in f["stations"]} == {shop.stall("03", "Stall 1"), shop.stall("03", "Stall 2")}
|
||||||
|
assert [x["display_name"] for x in f["workers"]] == ["Andy Welder", "Dave Dup"]
|
||||||
|
assert {x["id"] for x in f["workers"]} == {shop.uid("weld.andy"), shop.uid("dup.dave")}
|
||||||
|
# other filters do not narrow the facets
|
||||||
|
narrowed = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"], status="draft", facets=1)
|
||||||
|
assert narrowed["packets"] and all(c["status"] == "draft" for c in narrowed["packets"])
|
||||||
|
assert narrowed["facets"] == f
|
||||||
|
unit = get_ok(shop("admin.ann"), "/api/packets", unit_id=world.units["f1"], facets=1)["facets"]
|
||||||
|
assert [x["name"] for x in unit["stations"]] == ["Stall 1"]
|
||||||
|
assert [x["display_name"] for x in unit["workers"]] == ["Andy Welder"]
|
||||||
|
other = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fb"], facets=1)["facets"]
|
||||||
|
assert other == {"stations": [], "workers": []}
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ the boards
|
||||||
|
|
||||||
|
|
||||||
|
def board_ids(b: dict[str, Any]) -> set[int]:
|
||||||
|
out = {int(c["id"]) for col in b["columns"].values() for c in col}
|
||||||
|
for d in b.get("depts", []):
|
||||||
|
for st in d["stations"]:
|
||||||
|
out |= {int(c["id"]) for c in st["packets"]}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def steady(k: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {key: v for key, v in k.items() if key != "elapsed_ms"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_shop_board_filters_keep_the_kpis(world: FilterWorld) -> None:
|
||||||
|
shop = world.shop
|
||||||
|
wes = shop("sup.wes")
|
||||||
|
base = get_ok(wes, "/api/shop/board")
|
||||||
|
assert "facets" in base and set(base["facets"]) == {"stations", "workers"}
|
||||||
|
assert {"Stall 1", "Stall 2"} <= {x["name"] for x in base["facets"]["stations"]}
|
||||||
|
assert world.ids("r1", "a1", "a2", "rv1", "q1") <= board_ids(base)
|
||||||
|
cases: list[tuple[dict[str, Any], set[int]]] = [
|
||||||
|
({"station_id": shop.stall("03", "Stall 1")}, world.ids("a1")),
|
||||||
|
({"welder_id": shop.uid("dup.dave")}, world.ids("a2")),
|
||||||
|
({"q": "ZXa2"}, world.ids("a2")),
|
||||||
|
({"status": "review"}, world.ids("rv1")),
|
||||||
|
({"status": "station"}, world.ids("a1", "a2")),
|
||||||
|
({"flags": "co"}, world.ids("r1")),
|
||||||
|
({"flags": "week"}, world.ids("w1", "a1", "w2", "rv1")), # the warehouse column holds w1 and w2
|
||||||
|
({"q": "nothing-matches-this"}, set()),
|
||||||
|
]
|
||||||
|
for params, want in cases:
|
||||||
|
b = get_ok(wes, "/api/shop/board", **params)
|
||||||
|
assert board_ids(b) == want, params
|
||||||
|
# the KPIs follow the project and unit only (elapsed_ms is a running clock, so it moves between calls)
|
||||||
|
assert steady(b["kpis"]) == steady(base["kpis"]), params
|
||||||
|
assert "facets" in b
|
||||||
|
|
||||||
|
|
||||||
|
def test_stall_board_filters(world: FilterWorld) -> None:
|
||||||
|
shop = world.shop
|
||||||
|
andy = shop("weld.andy")
|
||||||
|
stall1 = shop.stall("03", "Stall 1")
|
||||||
|
base = get_ok(andy, "/api/shop/stall", station_id=stall1)
|
||||||
|
assert {c["id"] for c in base["mine"]} == world.ids("a1") and "facets" in base
|
||||||
|
assert get_ok(andy, "/api/shop/stall", station_id=stall1, q="ZXa1")["mine"][0]["id"] == world.k["a1"]
|
||||||
|
none = get_ok(andy, "/api/shop/stall", station_id=stall1, q="nothing-matches-this")
|
||||||
|
assert none["mine"] == [] and none["submitted"] == []
|
||||||
|
assert get_ok(andy, "/api/shop/stall", station_id=stall1, status="review")["mine"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_warehouse_board_filters_keep_the_kpis(world: FilterWorld) -> None:
|
||||||
|
wade = world.shop("wh.wade")
|
||||||
|
base = get_ok(wade, "/api/warehouse/board")
|
||||||
|
assert world.ids("w1", "w2") <= board_ids(base) and "facets" in base
|
||||||
|
for params, want in (
|
||||||
|
({"flags": "short"}, world.ids("w2")),
|
||||||
|
({"q": "ZXw1"}, world.ids("w1")),
|
||||||
|
({"flags": "overdue"}, world.ids("w1")),
|
||||||
|
({"flags": "mine"}, world.ids("w1", "w2")),
|
||||||
|
):
|
||||||
|
b = get_ok(wade, "/api/warehouse/board", **params)
|
||||||
|
assert board_ids(b) & world.ids("w1", "w2") == want, params
|
||||||
|
assert b["kpis"] == base["kpis"], params
|
||||||
|
|
||||||
|
|
||||||
|
def test_qc_home_filters_keep_the_kpis(world: FilterWorld) -> None:
|
||||||
|
quinn = world.shop("qc.quinn")
|
||||||
|
base = get_ok(quinn, "/api/qc/home")
|
||||||
|
assert [c["id"] for c in base["columns"]["ready"]] == [world.k["q1"]] and "facets" in base
|
||||||
|
found = get_ok(quinn, "/api/qc/home", q="ZXq1")
|
||||||
|
assert [c["id"] for c in found["columns"]["ready"]] == [world.k["q1"]]
|
||||||
|
none = get_ok(quinn, "/api/qc/home", q="nothing-matches-this")
|
||||||
|
assert none["columns"]["ready"] == []
|
||||||
|
assert none["kpis"] == base["kpis"] and found["kpis"] == base["kpis"]
|
||||||
|
other = get_ok(quinn, "/api/qc/home", project_id=world.projects["fb"])
|
||||||
|
assert other["columns"]["ready"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_filters_never_widen_access(world: FilterWorld) -> None:
|
||||||
|
"""A board still refuses who it refused; a filter cannot open another department's board."""
|
||||||
|
r = world.shop("multi.mia").get("/api/shop/board", params={"dept": "03", "status": "open"})
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
r = world.shop("weld.andy").get("/api/shop/board", params={"flags": "mine"})
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
@@ -8,9 +8,13 @@ stamp per mode. Nothing here reads VISUAL or M-Files."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -31,7 +35,8 @@ from qchelpers import g
|
|||||||
from shophelpers import Shop, open_shop, run
|
from shophelpers import Shop, open_shop, run
|
||||||
|
|
||||||
from packetstation.pdf import databook, stamps
|
from packetstation.pdf import databook, stamps
|
||||||
from packetstation.pdf.style import clean
|
from packetstation.pdf.doc import PAD_H, Doc
|
||||||
|
from packetstation.pdf.style import L, clean, fmt_clock_hhmm, fmt_clock_hms, round_half_up
|
||||||
from packetstation.settings import Settings
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
pytestmark = needs_pg
|
pytestmark = needs_pg
|
||||||
@@ -40,6 +45,7 @@ NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
|
|||||||
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
|
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
|
||||||
SECTIONS = [1, 2, 3, 4, 5, 6, 7]
|
SECTIONS = [1, 2, 3, 4, 5, 6, 7]
|
||||||
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
|
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
|
||||||
|
DUMP_DIR = os.environ.get("DATABOOK_DUMP_DIR") # off unless set
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -61,6 +67,17 @@ def build(shop: Shop, pid: int, mode: str = "preview", **kw: Any) -> Any:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dump(name: str, built: Any, pkt: dict[str, Any]) -> None:
|
||||||
|
"""With DATABOOK_DUMP_DIR set to a folder, keep the book and the packet read there (for the side by side
|
||||||
|
comparison with the old build's data book). Off by default."""
|
||||||
|
out = DUMP_DIR
|
||||||
|
if not out:
|
||||||
|
return
|
||||||
|
Path(out).mkdir(parents=True, exist_ok=True)
|
||||||
|
(Path(out) / f"{name}.pdf").write_bytes(built.bytes)
|
||||||
|
(Path(out) / f"{name}.json").write_text(json.dumps({"packet": pkt, "index": built.index}, default=str, indent=1))
|
||||||
|
|
||||||
|
|
||||||
def index(built: Any) -> list[dict[str, Any]]:
|
def index(built: Any) -> list[dict[str, Any]]:
|
||||||
keys = ("section", "doc", "ref", "rev", "page", "warn", "count")
|
keys = ("section", "doc", "ref", "rev", "page", "warn", "count")
|
||||||
return [{k: g(r, k) for k in keys} for r in built.index]
|
return [{k: g(r, k) for k in keys} for r in built.index]
|
||||||
@@ -74,6 +91,7 @@ def test_data_book_preview(shop: Shop) -> None:
|
|||||||
pkt = rich_complete(shop)
|
pkt = rich_complete(shop)
|
||||||
pid = pkt["id"]
|
pid = pkt["id"]
|
||||||
built = build(shop, pid)
|
built = build(shop, pid)
|
||||||
|
dump("preview", built, pkt)
|
||||||
data: bytes = built.bytes
|
data: bytes = built.bytes
|
||||||
assert data[:5] == b"%PDF-"
|
assert data[:5] == b"%PDF-"
|
||||||
reader = pdf_reader(data)
|
reader = pdf_reader(data)
|
||||||
@@ -141,6 +159,17 @@ def test_data_book_preview(shop: Shop) -> None:
|
|||||||
assert heat in trace, heat
|
assert heat in trace, heat
|
||||||
assert "open items" in trace.lower() and "item 3" in trace
|
assert "open items" in trace.lower() and "item 3" in trace
|
||||||
|
|
||||||
|
# phase 8 comparison with the old build's book (each line one fix)
|
||||||
|
assert '(9" x 11")' in index_text # a letter sheet is 8.5 in wide: Math.round gives 9
|
||||||
|
assert re.search(r"\b\d\d:\d\d WELD TIME", flat(texts[0])), flat(texts[0]) # HH:MM as the old cover
|
||||||
|
weld_page = next(r for r in rows if str(r["doc"]).startswith("Weld log"))["page"]
|
||||||
|
assert re.search(r"Time recorded \d\d:\d\d:\d\d \(target 4\.5 h\)", flat(texts[int(weld_page) - 1]))
|
||||||
|
qc_page = next(r for r in rows if r["section"] == 4)["page"]
|
||||||
|
qc_lines = texts[int(qc_page) - 1].splitlines()
|
||||||
|
assert "AM" not in qc_lines and "PM" not in qc_lines, qc_lines # the date and time stay on one line
|
||||||
|
hist_page = next(r for r in rows if r["section"] == 7)["page"]
|
||||||
|
assert "CO-1 " in flat(texts[int(hist_page) - 1]) and "documents changed" in flat(texts[int(hist_page) - 1])
|
||||||
|
|
||||||
# metadata
|
# metadata
|
||||||
meta = reader.metadata
|
meta = reader.metadata
|
||||||
assert meta.title == f"{pkt['unit']['project']['number']} Lot 001 Manufacturing Data Book - Spool 7 outlet"
|
assert meta.title == f"{pkt['unit']['project']['number']} Lot 001 Manufacturing Data Book - Spool 7 outlet"
|
||||||
@@ -199,3 +228,49 @@ def test_data_book_with_an_itp_document_has_no_itp_warning(shop: Shop) -> None:
|
|||||||
s5 = [r for r in rows if r["section"] == 5]
|
s5 = [r for r in rows if r["section"] == 5]
|
||||||
assert s5[0]["doc"].startswith("ITP"), s5
|
assert s5[0]["doc"].startswith("ITP"), s5
|
||||||
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
|
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- phase 8: against the old build's book
|
||||||
|
|
||||||
|
|
||||||
|
def test_co_summary_of_a_manual_change_without_sheets_reads_documents_changed() -> None:
|
||||||
|
co: dict[str, Any] = {"origin": "manual", "review": "approved", "reason": "Nozzle moved", "items": []}
|
||||||
|
assert databook.co_summary(co) == "documents changed" # the reason has its own column, as in coSummary()
|
||||||
|
co["items"] = [{"old_dwg": "D-5001", "old_rev": "A", "new_rev": "B"}]
|
||||||
|
assert databook.co_summary(co) == "D-5001 Rev A -> Rev B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clock_and_sheet_size_forms_of_the_old_build() -> None:
|
||||||
|
assert fmt_clock_hms(48_112) == "00:00:48" and fmt_clock_hms(3_725_000) == "01:02:05"
|
||||||
|
assert fmt_clock_hhmm(48_112) == "00:00" and fmt_clock_hhmm(37_800_000) == "10:30"
|
||||||
|
assert fmt_clock_hhmm(None) == "00:00"
|
||||||
|
assert round_half_up(612 / 72) == 9 and round_half_up(792 / 72) == 11 and round_half_up(2448 / 72) == 34
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_height(rows: list[list[Any]], cols: dict[int, dict[str, Any]]) -> float:
|
||||||
|
d = Doc(("T", "S"))
|
||||||
|
return d.table(None, rows, 200.0, cols) - 200.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_cells_wrap_where_autotable_did() -> None:
|
||||||
|
one_line = 8.3 * 1.15 + 2 * 4.2
|
||||||
|
# 79.4 pt of text in an 86 pt column: one line in the old book (autotable's 1 pt slack, jsPDF's widths)
|
||||||
|
assert abs(_rows_height([["10/18/2026 11:32 AM"]], {0: {"w": 86}}) - one_line) < 0.01
|
||||||
|
# clearly too long still wraps
|
||||||
|
assert _rows_height([["10/18/2026 11:32 AM and more"]], {0: {"w": 86}}) > one_line * 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_heads_sit_left_over_centred_columns() -> None:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
d = Doc(("T", "S"))
|
||||||
|
d.table(["ITEM", "QTY", "TEXT"], [["1", "2", "x"]], 200.0, {0: {"w": 30, "align": "center"}, 1: {"w": 30}})
|
||||||
|
xs: dict[str, float] = {}
|
||||||
|
|
||||||
|
def visit(text: str, cm: Any, tm: Any, font: Any, size: Any) -> None:
|
||||||
|
if text.strip() in ("ITEM", "QTY"):
|
||||||
|
xs[text.strip()] = float(tm[4])
|
||||||
|
|
||||||
|
PdfReader(io.BytesIO(d.finish())).pages[0].extract_text(visitor_text=visit)
|
||||||
|
assert xs["ITEM"] == pytest.approx(L + PAD_H, abs=0.5), xs # autotable column styles style the body only
|
||||||
|
assert xs["QTY"] == pytest.approx(L + 30 + PAD_H, abs=0.5), xs
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Phase 8 server smoke: one small world through every new read (the find bar on the list and the boards, the PM
|
||||||
|
reports, the exports) so the server agent's shapes are exercised end to end. The detailed checks live in
|
||||||
|
test_search.py, test_filters.py, test_pm_api.py, test_pm_export.py and test_presence.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fakedir import FakeDirectory
|
||||||
|
from pshelpers import app_conn, needs_pg
|
||||||
|
from pypdf import PdfReader
|
||||||
|
from shophelpers import Shop, draft, make_unit, ok, open_shop, ready_packet, run, set_running_since, start, to_station
|
||||||
|
from shophelpers import to_warehouse as send_to_wh
|
||||||
|
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||||
|
yield from open_shop(pg_settings, fake_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
|
||||||
|
settings = shop.settings
|
||||||
|
project_id, u1 = make_unit(settings, "L670", "052")
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
u2 = conn.execute(
|
||||||
|
"INSERT INTO units (project_id, lot, name, status, due) VALUES (%s, '053', 'Unit two', 'open',"
|
||||||
|
" current_date - 2) RETURNING id",
|
||||||
|
(project_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.execute("UPDATE projects SET due = current_date + 30 WHERE id = %s", (project_id,))
|
||||||
|
|
||||||
|
p_draft = draft(shop, u1, title="Inlet nozzle =SUM(A1)")
|
||||||
|
p_wh = send_to_wh(shop, draft(shop, u1, drawing="D-7000"))
|
||||||
|
ok(
|
||||||
|
shop("wh.wade").post(
|
||||||
|
f"/api/packets/{p_wh['id']}/wh",
|
||||||
|
json={"stage": "hold", "reason": "Waiting on purchasing / PO", "expect_stage": "incoming"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
p_st = start(shop, to_station(shop, ready_packet(shop, u2)))
|
||||||
|
set_running_since(settings, p_st["id"], 6 * 3600)
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
line = conn.execute("SELECT id FROM bom_lines WHERE packet_id = %s AND item = 2", (p_wh["id"],)).fetchone()[0]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open, reason, ref, eta)"
|
||||||
|
" VALUES (%s, %s, 2, 2, 'Not in stock', 'PO-881', current_date - 1)",
|
||||||
|
(p_wh["id"], line),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO qc_history (packet_id, at, result, inspector, note, dept_code, snapshot)"
|
||||||
|
" VALUES (%s, now() - interval '3 days', 'rejected', 'Quinn QC', 'NCR-12 · undercut', '03', '{}')",
|
||||||
|
(p_st["id"],),
|
||||||
|
)
|
||||||
|
co = conn.execute(
|
||||||
|
"INSERT INTO change_orders (packet_id, co_no, origin, review, ecn, reason, issued_at)"
|
||||||
|
" VALUES (%s, 1, 'manual', 'approved', 'ECN-77', 'Rev C', now() - interval '4 days') RETURNING id",
|
||||||
|
(p_st["id"],),
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, '03')", (co,))
|
||||||
|
run(settings, "UPDATE packets SET wps = 'WPS-99' WHERE id = %s", (p_draft["id"],))
|
||||||
|
|
||||||
|
sup = shop("sup.wes")
|
||||||
|
# The find bar on the list
|
||||||
|
got = ok(sup.get("/api/packets", params={"q": "lot 52", "project_id": project_id, "facets": 1}))
|
||||||
|
ids = {c["id"] for c in got["packets"]}
|
||||||
|
assert p_draft["id"] in ids and p_st["id"] not in ids, ids
|
||||||
|
assert len(got["facets"]["workers"]) == 1 and len(got["facets"]["stations"]) == 1
|
||||||
|
assert ok(sup.get("/api/packets", params={"q": "HT-20"}))["packets"][0]["id"] == p_st["id"]
|
||||||
|
assert [c["id"] for c in ok(sup.get("/api/packets", params={"q": "ECN-77"}))["packets"]] == [p_st["id"]]
|
||||||
|
assert [c["id"] for c in ok(sup.get("/api/packets", params={"status": "station"}))["packets"]] == [p_st["id"]]
|
||||||
|
shorts = ok(sup.get("/api/packets", params={"flags": "short"}))["packets"]
|
||||||
|
assert [c["id"] for c in shorts] == [p_wh["id"]]
|
||||||
|
mine = ok(sup.get("/api/packets", params={"flags": "mine", "mine_screen": "supervisor"}))["packets"]
|
||||||
|
assert {c["id"] for c in mine} == {p_draft["id"], p_wh["id"], p_st["id"]}
|
||||||
|
over = ok(sup.get("/api/packets", params={"flags": "overdue"}))["packets"]
|
||||||
|
assert [c["id"] for c in over] == [p_st["id"]]
|
||||||
|
for sort in ("updated", "project", "due", "status", "dept"):
|
||||||
|
assert len(ok(sup.get("/api/packets", params={"sort": sort, "project_id": project_id}))["packets"]) == 3
|
||||||
|
hand = ok(sup.get("/api/packets", params={"flag": "hand"}))["packets"]
|
||||||
|
assert {c["id"] for c in hand} == {p_draft["id"]}
|
||||||
|
|
||||||
|
# The boards keep their KPIs on the project scope
|
||||||
|
b = ok(sup.get("/api/shop/board", params={"q": "nothing-matches-this"}))
|
||||||
|
assert b["kpis"]["at_stations"] == 1 and not b["depts"][0]["stations"][0]["packets"]
|
||||||
|
assert "facets" in b
|
||||||
|
wh = ok(shop("wh.wade").get("/api/warehouse/board", params={"flags": "short"}))
|
||||||
|
assert [c["id"] for c in wh["columns"]["hold"]] == [p_wh["id"]] and "facets" in wh
|
||||||
|
qc = ok(shop("qc.quinn").get("/api/qc/home", params={"q": "zzz"}))
|
||||||
|
assert qc["kpis"]["waiting"] == 0 and "facets" in qc
|
||||||
|
stall = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": shop.stall(), "flags": "mine"}))
|
||||||
|
assert [c["id"] for c in stall["mine"]] == [p_st["id"]]
|
||||||
|
|
||||||
|
# Project Management
|
||||||
|
pmc = shop("pm.paula")
|
||||||
|
ov = ok(pmc.get("/api/pm/overview", params={"period": "30"}))
|
||||||
|
k = {x["key"]: x["n"] for x in ov["kpis"]}
|
||||||
|
assert k["open"] == 3 and k["holds"] == 1 and k["shorts"] == 1 and k["cos"] == 1 and k["rejects"] == 1, k
|
||||||
|
assert k["late_units"] == 1, k
|
||||||
|
assert ov["scope"]["label"] == "All projects" and ov["summary"][0].startswith("3 open packets")
|
||||||
|
assert sum(s["count"] for s in ov["pipeline"]) == 3
|
||||||
|
assert [(r["cells"][0], r["health"]) for r in ov["attention"]["rows"]] == [("L670052", "risk"), ("L670053", "late")]
|
||||||
|
pr = ok(pmc.get("/api/pm/projects", params={"expand": "all"}))
|
||||||
|
assert [r["lvl"] for r in pr["tables"][0]["rows"]][:3] == [0, 1, 1]
|
||||||
|
ag = ok(pmc.get("/api/pm/aging"))
|
||||||
|
assert [t["key"] for t in ag["tables"]] == ["aging_steps", "aging_open", "aging_history"]
|
||||||
|
hr = ok(pmc.get("/api/pm/hours"))
|
||||||
|
over_rows = hr["tables"][3]["rows"]
|
||||||
|
assert [r["pid"] for r in over_rows] == [p_st["id"]], hr
|
||||||
|
ql = ok(pmc.get("/api/pm/quality"))
|
||||||
|
assert [len(t["rows"]) for t in ql["tables"]][:4] == [1, 1, 1, 1], ql
|
||||||
|
fd = ok(pmc.get("/api/pm/find", params={"sort": "project", "project_id": project_id}))
|
||||||
|
assert fd["scope"]["label"] == "Project L670" and len(fd["packets"]) == 3
|
||||||
|
|
||||||
|
r = pmc.get("/api/pm/export.csv", params={"table": "all"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
text = r.content.decode("utf-8")
|
||||||
|
assert text.startswith("\ufeffPESCO Packet Station — All reports\r\n")
|
||||||
|
assert "attachment" in r.headers["content-disposition"]
|
||||||
|
assert pmc.get("/api/pm/export.csv", params={"table": "nope"}).status_code == 404
|
||||||
|
r = pmc.get("/api/pm/report.pdf")
|
||||||
|
assert r.status_code == 200 and r.content.startswith(b"%PDF")
|
||||||
|
|
||||||
|
reader = PdfReader(io.BytesIO(r.content))
|
||||||
|
assert len(reader.pages) > 5
|
||||||
|
assert "PRODUCTION STATUS REPORT" in reader.pages[0].extract_text()
|
||||||
|
assert all("Controlled copy" in pg.extract_text() for pg in reader.pages)
|
||||||
|
with app_conn(settings) as conn:
|
||||||
|
kinds = [x[0] for x in conn.execute("SELECT kind FROM print_log ORDER BY id")]
|
||||||
|
acts = [x[0] for x in conn.execute("SELECT action FROM audit_events WHERE action = 'report exported'")]
|
||||||
|
assert kinds == ["report-csv", "report-pdf"] and len(acts) == 2
|
||||||
|
assert shop("clerk.clara").get("/api/pm/overview").status_code == 200
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""Phase 8: the Project Management read endpoints against the PM world (pmhelpers.py).
|
||||||
|
|
||||||
|
The world is built once for the module; every test only reads it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pmhelpers import (
|
||||||
|
ATTENTION_HEAD,
|
||||||
|
BY_PERIOD,
|
||||||
|
FILED_KEYS,
|
||||||
|
HEADS,
|
||||||
|
HEALTH,
|
||||||
|
HOLD_NOTE,
|
||||||
|
HOLD_REASON,
|
||||||
|
KPI_ALERT,
|
||||||
|
KPI_KEYS,
|
||||||
|
KPI_TABS,
|
||||||
|
NOTES,
|
||||||
|
OPEN_KEYS,
|
||||||
|
PERIODS,
|
||||||
|
PIPELINE_COUNTS,
|
||||||
|
PM_STEPS,
|
||||||
|
REJECT_NCR,
|
||||||
|
REJECT_REASON,
|
||||||
|
STEP_LABEL,
|
||||||
|
STEP_OF,
|
||||||
|
TAB_TABLES,
|
||||||
|
UNIT_HEALTH,
|
||||||
|
PmWorld,
|
||||||
|
anon,
|
||||||
|
by_key,
|
||||||
|
get_ok,
|
||||||
|
kpi_labels,
|
||||||
|
module_shop,
|
||||||
|
pm_world,
|
||||||
|
project_rows,
|
||||||
|
table,
|
||||||
|
titles,
|
||||||
|
unit_rows,
|
||||||
|
)
|
||||||
|
from pshelpers import needs_pg
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
ENDPOINTS = ("overview", "projects", "aging", "hours", "quality", "find")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[PmWorld]:
|
||||||
|
with module_shop(tmp_path_factory.mktemp("pm_api")) as shop:
|
||||||
|
yield pm_world(shop)
|
||||||
|
|
||||||
|
|
||||||
|
def pm(w: PmWorld, what: str, sam: str = "pm.paula", **params: Any) -> dict[str, Any]:
|
||||||
|
return get_ok(w.shop(sam), f"/api/pm/{what}", **params)
|
||||||
|
|
||||||
|
|
||||||
|
def kpis(answer: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||||
|
return by_key(answer["kpis"])
|
||||||
|
|
||||||
|
|
||||||
|
def ids(w: PmWorld, *keys: str) -> set[int]:
|
||||||
|
return {w.k[k] for k in keys}
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ overview
|
||||||
|
|
||||||
|
|
||||||
|
def test_overview_kpis(world: PmWorld) -> None:
|
||||||
|
out = pm(world, "overview")
|
||||||
|
assert [k["key"] for k in out["kpis"]] == list(KPI_KEYS)
|
||||||
|
assert [k["label"] for k in out["kpis"]] == list(kpi_labels("90"))
|
||||||
|
assert [k["tab"] for k in out["kpis"]] == list(KPI_TABS)
|
||||||
|
assert {k["key"] for k in out["kpis"] if k["alert"]} == KPI_ALERT
|
||||||
|
n = {k: v["n"] for k, v in kpis(out).items()}
|
||||||
|
assert n == {
|
||||||
|
"open": len(OPEN_KEYS),
|
||||||
|
"late_units": 1,
|
||||||
|
"risk_units": 3,
|
||||||
|
"holds": 1,
|
||||||
|
"cos": 1,
|
||||||
|
"shorts": 1,
|
||||||
|
"rejects": 1,
|
||||||
|
"over": 1,
|
||||||
|
"filed": 1,
|
||||||
|
"lead": "10.0 d",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_overview_pipeline(world: PmWorld) -> None:
|
||||||
|
out = pm(world, "overview")
|
||||||
|
pipe = out["pipeline"]
|
||||||
|
assert [(p["key"], p["label"]) for p in pipe] == list(PM_STEPS)
|
||||||
|
assert {p["key"]: p["count"] for p in pipe} == PIPELINE_COUNTS
|
||||||
|
slow = [p["key"] for p in pipe if p["slowest"]]
|
||||||
|
assert slow == ["wh_hold"] # held five days; every other step is minutes old
|
||||||
|
hold = by_key(pipe)["wh_hold"]
|
||||||
|
assert hold["avg_s"] >= 5 * 86400 - 60 and hold["max_s"] >= hold["avg_s"]
|
||||||
|
assert hold["oldest"]["pid"] == world.k["k_hold"] and hold["oldest"]["job"]
|
||||||
|
empty = [p for p in pipe if p["count"] == 0]
|
||||||
|
assert all(p["oldest"] is None for p in empty)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overview_summary(world: PmWorld) -> None:
|
||||||
|
out = pm(world, "overview")
|
||||||
|
lines = out["summary"]
|
||||||
|
assert lines[0] == f"{len(OPEN_KEYS)} open packets in scope (All projects)."
|
||||||
|
assert lines[1].startswith("Late: ") and world.unit_number(1) in lines[1]
|
||||||
|
most = [x for x in lines if x.startswith("Most packets are at ")]
|
||||||
|
assert most and most[0] in (
|
||||||
|
f'Most packets are at "{STEP_LABEL["ready"]}" (2).',
|
||||||
|
f'Most packets are at "{STEP_LABEL["station"]}" (2).',
|
||||||
|
)
|
||||||
|
slow = [x for x in lines if x.startswith("Slowest step right now: ")]
|
||||||
|
assert slow and slow[0].startswith(f'Slowest step right now: "{STEP_LABEL["wh_hold"]}", averaging 5')
|
||||||
|
assert "Needs attention: 1 warehouse hold, 1 open change order, 1 open short, 1 Q.C. reject." in lines
|
||||||
|
assert "1 packet is over target hours." in lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_overview_attention(world: PmWorld) -> None:
|
||||||
|
att = pm(world, "overview")["attention"]
|
||||||
|
assert att["title"] == "Units late or at risk" and att["head"] == ATTENTION_HEAD
|
||||||
|
got = {int(r["unit_id"]): r["health"] for r in att["rows"]}
|
||||||
|
assert got == {world.units[n]: h for n, h in UNIT_HEALTH.items() if h in ("late", "risk")}
|
||||||
|
for r in att["rows"]:
|
||||||
|
assert r["cells"][-1] == HEALTH[r["health"]]
|
||||||
|
late = next(r for r in att["rows"] if r["health"] == "late")
|
||||||
|
assert late["cells"][0] == world.unit_number(1)
|
||||||
|
assert late["cells"][3] == "1 d over"
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ health and projects
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_per_unit_and_project(world: PmWorld) -> None:
|
||||||
|
out = pm(world, "projects", expand="all")
|
||||||
|
t = table(out, "projects")
|
||||||
|
assert t["title"] == titles()["projects"] and t["head"] == HEADS["projects"]
|
||||||
|
assert t["note"] == NOTES["projects"]
|
||||||
|
units = unit_rows(t)
|
||||||
|
for n, health in UNIT_HEALTH.items():
|
||||||
|
row = units[world.units[n]]
|
||||||
|
assert row["health"] == health, (n, row)
|
||||||
|
assert row["cells"][-1] == HEALTH[health]
|
||||||
|
assert row["project_id"] == world.projects["L670"]
|
||||||
|
assert units[world.units[5]]["cells"][12].startswith("100")
|
||||||
|
assert units[world.units[7]]["cells"][6] == "0"
|
||||||
|
projects = project_rows(t)
|
||||||
|
assert projects[world.projects["L670"]]["health"] == "late" # as healthy as its worst unit
|
||||||
|
assert projects[world.projects["L670"]]["cells"][-1] == "Late"
|
||||||
|
assert projects[world.projects["L489"]]["health"] == "empty"
|
||||||
|
assert world.projects["L333"] not in projects # closed, no packet in scope
|
||||||
|
assert units[world.units[52]]["health"] == "empty"
|
||||||
|
# the order: each project row, then its units
|
||||||
|
lvl = [(r["lvl"], r["project_id"]) for r in t["rows"]]
|
||||||
|
first = lvl.index((0, world.projects["L670"]))
|
||||||
|
assert all(x == (1, world.projects["L670"]) for x in lvl[first + 1 : first + 8])
|
||||||
|
|
||||||
|
|
||||||
|
def test_drill_down(world: PmWorld) -> None:
|
||||||
|
pa, pb = world.projects["L670"], world.projects["L489"]
|
||||||
|
closed = pm(world, "projects")
|
||||||
|
t = table(closed, "projects")
|
||||||
|
assert {r["lvl"] for r in t["rows"]} == {0}
|
||||||
|
assert set(project_rows(t)) == {pa, pb}
|
||||||
|
one = table(pm(world, "projects", expand=str(pb)), "projects")
|
||||||
|
assert set(unit_rows(one)) == {world.units[52]}
|
||||||
|
both = table(pm(world, "projects", expand=f"{pa},{pb}"), "projects")
|
||||||
|
assert set(unit_rows(both)) == set(world.units.values())
|
||||||
|
picked = table(pm(world, "projects", project_id=pa), "projects")
|
||||||
|
assert set(project_rows(picked)) == {pa}
|
||||||
|
assert set(unit_rows(picked)) == {world.units[n] for n in range(1, 8)}
|
||||||
|
# any filter other than the project: only units with a packet in scope
|
||||||
|
at_qc = table(pm(world, "projects", status="qc", expand="all"), "projects")
|
||||||
|
assert set(unit_rows(at_qc)) == {world.units[2]}
|
||||||
|
assert pa in project_rows(at_qc)
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ aging, hours, quality
|
||||||
|
|
||||||
|
|
||||||
|
def test_aging(world: PmWorld) -> None:
|
||||||
|
out = pm(world, "aging")
|
||||||
|
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["aging"])
|
||||||
|
assert [p["key"] for p in out["pipeline"]] == [k for k, _ in PM_STEPS]
|
||||||
|
for key in TAB_TABLES["aging"]:
|
||||||
|
t = table(out, key)
|
||||||
|
assert t["title"] == titles()[key] and t["head"] == HEADS[key] and t["note"] == NOTES[key]
|
||||||
|
steps = {r["cells"][0]: r for r in table(out, "aging_steps")["rows"]}
|
||||||
|
assert steps[STEP_LABEL["wh_hold"]]["cells"][1] == "1"
|
||||||
|
assert steps[STEP_LABEL["ready"]]["cells"][1] == "2"
|
||||||
|
opened = table(out, "aging_open")
|
||||||
|
assert {int(r["pid"]) for r in opened["rows"]} == ids(world, *OPEN_KEYS)
|
||||||
|
assert opened["rows"][0]["pid"] == world.k["k_hold"] and opened["rows"][0]["flag"] is True
|
||||||
|
assert opened["rows"][0]["cells"][2] == STEP_LABEL["wh_hold"]
|
||||||
|
fresh_rows = [r for r in opened["rows"] if r["pid"] in ids(world, "k_ready", "k_asg", "k_qc")]
|
||||||
|
assert fresh_rows and not any(r["flag"] for r in fresh_rows)
|
||||||
|
steps_of = {int(r["pid"]): r["cells"][2] for r in opened["rows"]}
|
||||||
|
for key in OPEN_KEYS:
|
||||||
|
assert steps_of[world.k[key]] == STEP_LABEL[STEP_OF[key]], key
|
||||||
|
assert table(out, "aging_history")["rows"], "the moves made by the world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hours(world: PmWorld) -> None:
|
||||||
|
out = pm(world, "hours")
|
||||||
|
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["hours"])
|
||||||
|
for key in TAB_TABLES["hours"]:
|
||||||
|
t = table(out, key)
|
||||||
|
assert t["title"] == titles()[key] and t["head"] == HEADS[key]
|
||||||
|
assert table(out, "hours_over")["note"] == NOTES["hours_over"]
|
||||||
|
dept = table(out, "hours_dept")["rows"]
|
||||||
|
assert len(dept) == 1 and "Weld Parts" in dept[0]["cells"][0]
|
||||||
|
# counted: the running packet, and every packet past review with target hours
|
||||||
|
assert dept[0]["cells"][1] == "6"
|
||||||
|
assert dept[0]["cells"][3] == "3.2" # ticket hours: the running packet's VISUAL rows only
|
||||||
|
assert dept[0]["cells"][4] == "27.0" # 6 x 4.5
|
||||||
|
assert dept[0]["cells"][7] == "1"
|
||||||
|
over = table(out, "hours_over")["rows"]
|
||||||
|
assert [r["pid"] for r in over] == [world.k["k_run"]]
|
||||||
|
cells = over[0]["cells"]
|
||||||
|
# 4 h stored plus the running span (2 h when the world was built, more by now)
|
||||||
|
timer = float(cells[3])
|
||||||
|
assert 6.0 <= timer < 8.0, cells
|
||||||
|
assert cells[4] == "3.2" and cells[5] == "4.5" and cells[6] == f"+{timer - 4.5:.1f} h"
|
||||||
|
assert "Andy Welder" in cells[1] or "Stall 1" in cells[1]
|
||||||
|
workers = {r["cells"][0]: r for r in table(out, "hours_worker")["rows"]}
|
||||||
|
assert set(workers) == {"Andy Welder", "—"}
|
||||||
|
assert workers["Andy Welder"]["cells"][1] == "5" and workers["—"]["cells"][1] == "1"
|
||||||
|
assert workers["Andy Welder"]["cells"][7] == "1"
|
||||||
|
projects = table(out, "hours_project")["rows"]
|
||||||
|
assert [r["cells"][0] for r in projects] == ["L670 Pump skids"]
|
||||||
|
variance = [r["cells"][5] for r in table(out, "hours_worker")["rows"]]
|
||||||
|
assert all(v.startswith(("+", "-", "0")) for v in variance)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality(world: PmWorld) -> None:
|
||||||
|
out = pm(world, "quality")
|
||||||
|
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["quality"])
|
||||||
|
for key in TAB_TABLES["quality"]:
|
||||||
|
t = table(out, key)
|
||||||
|
assert t["title"] == titles()[key] and t["head"] == HEADS[key]
|
||||||
|
rej = table(out, "quality_rejects")["rows"]
|
||||||
|
assert [r["pid"] for r in rej] == [world.k["k_rev"]]
|
||||||
|
assert rej[0]["cells"][2] == "—" # the reject was made before an inspector name was entered
|
||||||
|
assert REJECT_REASON in rej[0]["cells"][3] and REJECT_NCR in rej[0]["cells"][3]
|
||||||
|
shorts = table(out, "quality_shorts")["rows"]
|
||||||
|
assert [r["pid"] for r in shorts] == [world.k["k_short"]]
|
||||||
|
assert shorts[0]["flag"] is True and shorts[0]["cells"][5].endswith(" (late)")
|
||||||
|
assert shorts[0]["cells"][3] == "On order" and shorts[0]["cells"][4] == "PO-77"
|
||||||
|
assert shorts[0]["cells"][1].startswith("1 ") and shorts[0]["cells"][7] == STEP_LABEL["wh_pulling"]
|
||||||
|
cos = table(out, "quality_cos")["rows"]
|
||||||
|
assert [r["pid"] for r in cos] == [world.k["k_co"]]
|
||||||
|
assert cos[0]["cells"][1].startswith("CO-1") and cos[0]["flag"] is True # open three days
|
||||||
|
assert cos[0]["cells"][4] == "3.0 d"
|
||||||
|
holds = table(out, "quality_holds")["rows"]
|
||||||
|
assert [r["pid"] for r in holds] == [world.k["k_hold"]] and holds[0]["flag"] is True
|
||||||
|
assert holds[0]["cells"][1:4] == [HOLD_REASON, HOLD_NOTE, "Wade Warehouse"]
|
||||||
|
assert holds[0]["cells"][4] == "5.0 d"
|
||||||
|
back = table(out, "quality_sentback")["rows"]
|
||||||
|
assert [r["pid"] for r in back] == [world.k["k_rev"], world.k["k_draft"]] # newest first
|
||||||
|
assert back[1]["cells"][3] == "Wes Super" and back[1]["cells"][4] == "Missing drawing"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("period", ["30", "90", "365", "all"])
|
||||||
|
def test_every_period(world: PmWorld, period: str) -> None:
|
||||||
|
want = BY_PERIOD[period]
|
||||||
|
ov = pm(world, "overview", period=period)
|
||||||
|
sc = ov["scope"]["period"]
|
||||||
|
assert sc["value"] == period and sc["label"] == PERIODS[period]
|
||||||
|
assert (sc["start"] is None) == (period == "all")
|
||||||
|
assert [k["label"] for k in ov["kpis"]] == list(kpi_labels(period))
|
||||||
|
n = {k: v["n"] for k, v in kpis(ov).items()}
|
||||||
|
assert n["rejects"] == want["rejects"] and n["filed"] == want["filed"]
|
||||||
|
if "lead" in want:
|
||||||
|
assert n["lead"] == want["lead"]
|
||||||
|
q = pm(world, "quality", period=period)
|
||||||
|
assert len(table(q, "quality_sentback")["rows"]) == want["sentback"]
|
||||||
|
assert len(table(q, "quality_rejects")["rows"]) == want["rejects"]
|
||||||
|
assert table(q, "quality_rejects")["title"] == titles(period)["quality_rejects"]
|
||||||
|
# the open counts do not follow the period
|
||||||
|
assert n["open"] == len(OPEN_KEYS) and n["holds"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ find, scope, access
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_sorted(world: PmWorld) -> None:
|
||||||
|
every = ids(world, *OPEN_KEYS, *FILED_KEYS)
|
||||||
|
out = pm(world, "find", sort="project")
|
||||||
|
got = [c["id"] for c in out["packets"]]
|
||||||
|
assert set(got) == every and len(got) == len(every)
|
||||||
|
units = [c["unit"]["number"] for c in out["packets"]]
|
||||||
|
assert units == sorted(units)
|
||||||
|
due = pm(world, "find", sort="due")["packets"]
|
||||||
|
assert due[0]["unit"]["number"] == world.unit_number(1)
|
||||||
|
late = [c["due_effective"] for c in due]
|
||||||
|
assert late == sorted(late, key=lambda d: (d is None, d or ""))
|
||||||
|
upd = [c["updated_at"] for c in pm(world, "find")["packets"]]
|
||||||
|
assert upd == sorted(upd, reverse=True)
|
||||||
|
scoped = pm(world, "find", unit_id=world.units[2])["packets"]
|
||||||
|
assert {c["id"] for c in scoped} == ids(world, "k_rev", "k_qc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scope_label(world: PmWorld) -> None:
|
||||||
|
pa = world.projects["L670"]
|
||||||
|
sc = pm(world, "overview")["scope"]
|
||||||
|
assert sc["label"] == "All projects" and sc["project"] is None and sc["unit"] is None
|
||||||
|
assert sc["count"] == len(pm(world, "find")["packets"])
|
||||||
|
sc = pm(world, "overview", project_id=pa)["scope"]
|
||||||
|
assert sc["label"] == "Project L670"
|
||||||
|
assert sc["project"] == {"id": pa, "number": "L670", "name": "Pump skids"}
|
||||||
|
sc = pm(world, "overview", project_id=pa, unit_id=world.units[1])["scope"]
|
||||||
|
assert sc["label"].startswith("Unit L670001") and sc["unit"] == {"id": world.units[1], "number": "L670001"}
|
||||||
|
assert sc["count"] == 2
|
||||||
|
sc = pm(world, "overview", q="spool")["scope"]
|
||||||
|
assert sc["label"] == 'All projects · search "spool"'
|
||||||
|
sc = pm(world, "overview", project_id=pa, dept="03", q="held")["scope"]
|
||||||
|
parts = sc["label"].split(" · ")
|
||||||
|
assert parts[0] == "Project L670" and parts[-1] == 'search "held"' and len(parts) == 3
|
||||||
|
assert sc["count"] == 1
|
||||||
|
flags = pm(world, "overview", flags="short")["scope"]
|
||||||
|
assert flags["label"].startswith("All projects · ") and flags["count"] == 1
|
||||||
|
# mine is ignored on the PM screens
|
||||||
|
assert pm(world, "overview", flags="mine")["scope"]["count"] == sc_count_all(world)
|
||||||
|
|
||||||
|
|
||||||
|
def sc_count_all(world: PmWorld) -> int:
|
||||||
|
return int(pm(world, "overview")["scope"]["count"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_scope_follows_the_project(world: PmWorld) -> None:
|
||||||
|
pb = world.projects["L489"]
|
||||||
|
out = pm(world, "overview", project_id=pb)
|
||||||
|
n = {k: v["n"] for k, v in kpis(out).items()}
|
||||||
|
assert n["open"] == 0 and n["holds"] == 0 and n["late_units"] == 0
|
||||||
|
assert out["attention"]["rows"] == []
|
||||||
|
q = pm(world, "quality", project_id=pb)
|
||||||
|
assert all(table(q, k)["rows"] == [] for k in TAB_TABLES["quality"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sam", ["pm.paula", "clerk.clara", "weld.andy"])
|
||||||
|
def test_every_signed_in_role_reads(world: PmWorld, sam: str) -> None:
|
||||||
|
for what in ENDPOINTS:
|
||||||
|
r = world.shop(sam).get(f"/api/pm/{what}")
|
||||||
|
assert r.status_code == 200, (sam, what, r.text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsigned_is_refused_and_nothing_writes(world: PmWorld) -> None:
|
||||||
|
c = anon(world.shop)
|
||||||
|
for what in ENDPOINTS:
|
||||||
|
r = c.get(f"/api/pm/{what}")
|
||||||
|
assert r.status_code == 401, (what, r.text)
|
||||||
|
for what in ENDPOINTS:
|
||||||
|
r = world.shop("pm.paula").post(f"/api/pm/{what}", json={})
|
||||||
|
assert r.status_code in (404, 405), (what, r.status_code)
|
||||||
|
# the PM user cannot move a packet
|
||||||
|
pkt = get_ok(world.shop("pm.paula"), f"/api/packets/{world.k['k_ready']}")
|
||||||
|
r = world.shop("pm.paula").post(
|
||||||
|
f"/api/packets/{pkt['id']}/transitions",
|
||||||
|
json={"action": "assign", "expect": {"status": pkt["status"], "dept": pkt["dept"]["code"]}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_period_is_refused(world: PmWorld) -> None:
|
||||||
|
r = world.shop("pm.paula").get("/api/pm/overview", params={"period": "7"})
|
||||||
|
assert r.status_code in (400, 422), r.text
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Phase 8: the Project Management exports. The CSV per table and for every table (the header rows, the formula
|
||||||
|
guard, the BOM and CRLF), the production status report PDF (pages, cover, the controlled copy stamp on every page),
|
||||||
|
and one print_log row plus one audit row `report exported` for each export.
|
||||||
|
|
||||||
|
The PM world (pmhelpers.py) is built once for the module; one draft's title starts with `=`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from closehelpers import flat, page_texts, pdf_pages, q1, qall
|
||||||
|
from pmhelpers import (
|
||||||
|
FORMULA_TITLE,
|
||||||
|
HEADS,
|
||||||
|
PERIODS,
|
||||||
|
PmWorld,
|
||||||
|
anon,
|
||||||
|
module_shop,
|
||||||
|
pm_world,
|
||||||
|
titles,
|
||||||
|
)
|
||||||
|
from pshelpers import needs_pg
|
||||||
|
from shophelpers import run
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
TABLE_KEYS = tuple(HEADS) # projects, aging_*, hours_*, quality_*, jobs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[PmWorld]:
|
||||||
|
with module_shop(tmp_path_factory.mktemp("pm_export")) as shop:
|
||||||
|
w = pm_world(shop)
|
||||||
|
run(shop.settings, "UPDATE packets SET title = %s WHERE id = %s", (FORMULA_TITLE, w.k["k_draft"]))
|
||||||
|
yield w
|
||||||
|
|
||||||
|
|
||||||
|
def export(w: PmWorld, table: str, sam: str = "pm.paula", **params: Any) -> Any:
|
||||||
|
return w.shop(sam).get("/api/pm/export.csv", params={"table": table, **params})
|
||||||
|
|
||||||
|
|
||||||
|
def parse(body: bytes) -> list[list[str]]:
|
||||||
|
assert body.startswith(b"\xef\xbb\xbf"), body[:10] # the BOM, for Excel
|
||||||
|
assert b"\r\n" in body and body.count(b"\n") == body.count(b"\r\n"), "every line ends CRLF"
|
||||||
|
return list(csv.reader(io.StringIO(body.decode("utf-8-sig"), newline="")))
|
||||||
|
|
||||||
|
|
||||||
|
def filename(r: Any) -> str:
|
||||||
|
cd = r.headers["content-disposition"]
|
||||||
|
m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', cd)
|
||||||
|
assert m, cd
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def print_rows(w: PmWorld, kind: str) -> list[Any]:
|
||||||
|
return qall(
|
||||||
|
w.settings,
|
||||||
|
"SELECT what, pages, by_name, packet_id, unit_id FROM print_log WHERE kind = %s ORDER BY id",
|
||||||
|
(kind,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def audit_rows(w: PmWorld) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
r[0] for r in qall(w.settings, "SELECT detail FROM audit_events WHERE action = 'report exported' ORDER BY id")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def header_ok(rows: list[list[str]], name: str, label: str = "All projects", period: str = "90") -> None:
|
||||||
|
assert rows[0] == [f"PESCO Packet Station — {name}"], rows[0]
|
||||||
|
assert rows[1] == ["Scope", label]
|
||||||
|
assert rows[2] == ["History period", PERIODS[period]]
|
||||||
|
assert rows[3][0] == "Exported" and rows[3][2] == "by" and rows[3][3] == "Paula PM" and rows[3][1]
|
||||||
|
assert rows[4] == [] or rows[4] == [""]
|
||||||
|
|
||||||
|
|
||||||
|
def table_at(rows: list[list[str]], title: str) -> tuple[list[str], list[list[str]]]:
|
||||||
|
"""The head and the body rows of the table titled `title` in a parsed CSV."""
|
||||||
|
i = rows.index([title])
|
||||||
|
head = rows[i + 1]
|
||||||
|
body = []
|
||||||
|
for r in rows[i + 2 :]:
|
||||||
|
if r in ([], [""]):
|
||||||
|
break
|
||||||
|
body.append(r)
|
||||||
|
return head, body
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ CSV
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", TABLE_KEYS)
|
||||||
|
def test_csv_per_table(world: PmWorld, key: str) -> None:
|
||||||
|
r = export(world, key)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.headers["content-type"].startswith("text/csv")
|
||||||
|
assert "attachment" in r.headers["content-disposition"]
|
||||||
|
name = filename(r)
|
||||||
|
assert name.startswith("PacketStation_") and name.endswith(f"_{world.today.isoformat()}.csv"), name
|
||||||
|
assert re.fullmatch(r"PacketStation_[A-Za-z0-9_]+_\d{4}-\d{2}-\d{2}\.csv", name), name
|
||||||
|
rows = parse(r.content)
|
||||||
|
title = titles()[key]
|
||||||
|
assert rows[0][0].startswith("PESCO Packet Station — ")
|
||||||
|
head, body = table_at(rows, title)
|
||||||
|
assert head == HEADS[key]
|
||||||
|
assert all(len(b) == len(head) for b in body), key
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_jobs_and_the_formula_guard(world: PmWorld) -> None:
|
||||||
|
r = export(world, "jobs")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert filename(r) == f"PacketStation_Jobs_{world.today.isoformat()}.csv"
|
||||||
|
rows = parse(r.content)
|
||||||
|
header_ok(rows, "Jobs")
|
||||||
|
head, body = table_at(rows, titles()["jobs"])
|
||||||
|
assert head == HEADS["jobs"]
|
||||||
|
assert len(body) == 14 # 12 open and 2 filed
|
||||||
|
col = head.index("Title")
|
||||||
|
assert "'" + FORMULA_TITLE in [b[col] for b in body]
|
||||||
|
assert FORMULA_TITLE not in [b[col] for b in body]
|
||||||
|
every = [c for b in rows for c in b]
|
||||||
|
assert not any(c[:1] in ("=", "+", "-", "@", "\t") for c in every if c), "the formula guard on every cell"
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_all(world: PmWorld) -> None:
|
||||||
|
r = export(world, "all")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert filename(r) == f"PacketStation_All_reports_{world.today.isoformat()}.csv"
|
||||||
|
rows = parse(r.content)
|
||||||
|
header_ok(rows, "All reports")
|
||||||
|
for key in TABLE_KEYS:
|
||||||
|
head, _ = table_at(rows, titles()[key])
|
||||||
|
assert head == HEADS[key], key
|
||||||
|
_, units = table_at(rows, titles()["projects"])
|
||||||
|
assert len(units) >= 2 + 8 # every project row and every unit (expand all)
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_follows_scope_and_period(world: PmWorld) -> None:
|
||||||
|
pa = world.projects["L670"]
|
||||||
|
r = export(world, "quality_sentback", project_id=pa, period="365")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
rows = parse(r.content)
|
||||||
|
assert rows[1] == ["Scope", "Project L670"]
|
||||||
|
assert rows[2] == ["History period", PERIODS["365"]]
|
||||||
|
_, body = table_at(rows, titles("365")["quality_sentback"])
|
||||||
|
assert len(body) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_unknown_table(world: PmWorld) -> None:
|
||||||
|
r = export(world, "salaries")
|
||||||
|
assert r.status_code == 404 and r.json()["code"] == "not_found", r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_is_logged(world: PmWorld) -> None:
|
||||||
|
before_print = len(print_rows(world, "report-csv"))
|
||||||
|
before_audit = len(audit_rows(world))
|
||||||
|
assert export(world, "jobs").status_code == 200
|
||||||
|
rows = print_rows(world, "report-csv")
|
||||||
|
assert len(rows) == before_print + 1
|
||||||
|
what, pages, by_name, packet_id, unit_id = rows[-1]
|
||||||
|
assert what.endswith(" · CSV · All projects") and by_name == "Paula PM"
|
||||||
|
assert packet_id is None and unit_id is None and pages is None
|
||||||
|
audit = audit_rows(world)
|
||||||
|
assert len(audit) == before_audit + 1
|
||||||
|
detail = audit[-1]
|
||||||
|
assert set(detail) >= {"name", "format", "scope", "period"}
|
||||||
|
assert str(detail["format"]).lower() == "csv" and detail["scope"] == "All projects"
|
||||||
|
assert detail["period"] in ("90", PERIODS["90"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_needs_a_session(world: PmWorld) -> None:
|
||||||
|
c = anon(world.shop)
|
||||||
|
assert c.get("/api/pm/export.csv", params={"table": "all"}).status_code == 401
|
||||||
|
assert c.get("/api/pm/report.pdf").status_code == 401
|
||||||
|
assert world.shop("clerk.clara").get("/api/pm/export.csv", params={"table": "jobs"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ PDF
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_pdf(world: PmWorld) -> None:
|
||||||
|
before = len(print_rows(world, "report-pdf"))
|
||||||
|
before_audit = len(audit_rows(world))
|
||||||
|
r = world.shop("pm.paula").get("/api/pm/report.pdf")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.headers["content-type"].startswith("application/pdf")
|
||||||
|
assert r.headers["content-disposition"].startswith("inline")
|
||||||
|
assert filename(r) == f"PESCO_Production_Status_{world.today.isoformat()}.pdf"
|
||||||
|
data = r.content
|
||||||
|
assert data[:5] == b"%PDF-"
|
||||||
|
pages = pdf_pages(data)
|
||||||
|
assert pages > 5
|
||||||
|
texts = [flat(t) for t in page_texts(data)]
|
||||||
|
assert "PRODUCTION STATUS REPORT" in texts[0]
|
||||||
|
assert "Packet Station · All projects" in texts[0]
|
||||||
|
for i, t in enumerate(texts):
|
||||||
|
assert "Controlled copy" in t, f"page {i + 1} has no stamp"
|
||||||
|
body = texts[1:]
|
||||||
|
for i, t in enumerate(body):
|
||||||
|
assert "Production status report" in t, i
|
||||||
|
assert "CONFIDENTIAL & PROPRIETARY" in t, i
|
||||||
|
every = " ".join(texts)
|
||||||
|
for part in ("SUMMARY", "PROJECTS", "FLOW", "LABOR", "ISSUES"):
|
||||||
|
assert part in every, part
|
||||||
|
assert "L670" in every
|
||||||
|
# logged once with the page count
|
||||||
|
rows = print_rows(world, "report-pdf")
|
||||||
|
assert len(rows) == before + 1
|
||||||
|
what, logged_pages, by_name, packet_id, _ = rows[-1]
|
||||||
|
assert what == "Production status report · PDF · All projects"
|
||||||
|
assert logged_pages == pages and by_name == "Paula PM" and packet_id is None
|
||||||
|
audit = audit_rows(world)
|
||||||
|
assert len(audit) == before_audit + 1 and str(audit[-1]["format"]).lower() == "pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_pdf_download_and_scope(world: PmWorld) -> None:
|
||||||
|
r = world.shop("pm.paula").get(
|
||||||
|
"/api/pm/report.pdf", params={"download": 1, "project_id": world.projects["L670"], "period": "all"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.headers["content-disposition"].startswith("attachment")
|
||||||
|
first = flat(page_texts(r.content)[0])
|
||||||
|
assert "Packet Station · Project L670" in first
|
||||||
|
row = q1(world.settings, "SELECT what FROM print_log WHERE kind = 'report-pdf' ORDER BY id DESC LIMIT 1")
|
||||||
|
assert row[0] == "Production status report · PDF · Project L670"
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""Phase 8: presence. Who is on which screen and packet, with the role shown; locked sessions drop out and come
|
||||||
|
back on unlock; revoked sessions are gone at once. Driven through the EventHub directly (as test_events.py does)
|
||||||
|
and through the API (`/api/events`, `/api/auth/lock`, `/api/auth/unlock`, the idle lock in `resolve`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator, Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fakedir import FAKE_PASSWORD, FakeDirectory
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pshelpers import ORIGIN_HEADERS, needs_pg, sign_in
|
||||||
|
|
||||||
|
from packetstation import db, events
|
||||||
|
from packetstation.settings import Settings
|
||||||
|
|
||||||
|
USER_KEYS = {"user_id", "display_name", "role", "screen", "packet_id", "since"}
|
||||||
|
|
||||||
|
|
||||||
|
def _sub(
|
||||||
|
hub: events.EventHub,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
screen: str | None = None,
|
||||||
|
packet_id: int | None = None,
|
||||||
|
role: str = "Supervisor",
|
||||||
|
user_id: Any = None,
|
||||||
|
session_id: Any = None,
|
||||||
|
) -> events.Subscriber:
|
||||||
|
return hub.subscribe(
|
||||||
|
session_id=session_id or uuid.uuid4(),
|
||||||
|
user_id=user_id or uuid.uuid4(),
|
||||||
|
display_name=name,
|
||||||
|
screen=screen,
|
||||||
|
packet_id=packet_id,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _drain(q: asyncio.Queue[events.Item]) -> list[events.Item]:
|
||||||
|
await asyncio.sleep(0) # let call_soon_threadsafe callbacks run
|
||||||
|
out = []
|
||||||
|
while not q.empty():
|
||||||
|
out.append(q.get_nowait())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _on(hub: events.EventHub, packet_id: int) -> list[tuple[str, str, str | None]]:
|
||||||
|
return [(u["display_name"], u["role"], u["screen"]) for u in hub.presence()["users"] if u["packet_id"] == packet_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _names(hub: events.EventHub) -> list[str]:
|
||||||
|
return [u["display_name"] for u in hub.presence()["users"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _session(hub: events.EventHub, sub: events.Subscriber, action: str) -> None:
|
||||||
|
hub._deliver(json.dumps({"event": "session", "session_id": sub.session_id, "action": action}))
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ the hub
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_users_on_one_packet_see_each_other() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
hub = events.EventHub()
|
||||||
|
wes = _sub(hub, "Wes Super", screen="supervisor", packet_id=7, role="Supervisor")
|
||||||
|
andy = _sub(hub, "Andy Welder", screen="stall", packet_id=7, role="Weld Stall")
|
||||||
|
_sub(hub, "Clara Clerk", screen="clerical", role="Clerical")
|
||||||
|
users = hub.presence()["users"]
|
||||||
|
assert all(set(u) == USER_KEYS for u in users), users
|
||||||
|
assert all(u["since"] for u in users)
|
||||||
|
assert sorted(_on(hub, 7)) == [
|
||||||
|
("Andy Welder", "Weld Stall", "stall"),
|
||||||
|
("Wes Super", "Supervisor", "supervisor"),
|
||||||
|
]
|
||||||
|
last = (await _drain(wes.queue))[-1]
|
||||||
|
assert last[0] == "presence" and {u["display_name"] for u in last[1]["users"]} == {
|
||||||
|
"Wes Super",
|
||||||
|
"Andy Welder",
|
||||||
|
"Clara Clerk",
|
||||||
|
}
|
||||||
|
assert (await _drain(andy.queue))[-1] == last
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_entry_per_user_screen_and_packet() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
hub = events.EventHub()
|
||||||
|
uid = uuid.uuid4()
|
||||||
|
_sub(hub, "Wes Super", screen="supervisor", packet_id=7, user_id=uid)
|
||||||
|
_sub(hub, "Wes Super", screen="supervisor", packet_id=7, user_id=uid) # a second tab
|
||||||
|
_sub(hub, "Wes Super", screen="supervisor", packet_id=8, user_id=uid)
|
||||||
|
assert len(_on(hub, 7)) == 1 and len(_on(hub, 8)) == 1
|
||||||
|
assert _names(hub) == ["Wes Super", "Wes Super"]
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaving_drops_out() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
hub = events.EventHub()
|
||||||
|
wes = _sub(hub, "Wes Super", packet_id=7)
|
||||||
|
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
|
||||||
|
await _drain(wes.queue)
|
||||||
|
hub.unsubscribe(andy)
|
||||||
|
got = await _drain(wes.queue)
|
||||||
|
assert got and got[-1][0] == "presence"
|
||||||
|
assert [u["display_name"] for u in got[-1][1]["users"]] == ["Wes Super"]
|
||||||
|
assert _on(hub, 7) == [("Wes Super", "Supervisor", None)]
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_locked_hides_and_unlocked_brings_back() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
hub = events.EventHub()
|
||||||
|
wes = _sub(hub, "Wes Super", packet_id=7)
|
||||||
|
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
|
||||||
|
await _drain(wes.queue)
|
||||||
|
await _drain(andy.queue)
|
||||||
|
|
||||||
|
_session(hub, andy, "locked")
|
||||||
|
assert _names(hub) == ["Wes Super"]
|
||||||
|
to_wes = await _drain(wes.queue)
|
||||||
|
assert [n for n, _ in to_wes] == ["presence"] # the others get presence, never the session event
|
||||||
|
assert [u["display_name"] for u in to_wes[-1][1]["users"]] == ["Wes Super"]
|
||||||
|
to_andy = await _drain(andy.queue)
|
||||||
|
assert ("session", {"session_id": andy.session_id, "action": "locked"}) in to_andy
|
||||||
|
|
||||||
|
_session(hub, andy, "unlocked")
|
||||||
|
assert sorted(_names(hub)) == ["Andy Welder", "Wes Super"]
|
||||||
|
to_wes = await _drain(wes.queue)
|
||||||
|
assert to_wes and to_wes[-1][0] == "presence" and len(to_wes[-1][1]["users"]) == 2
|
||||||
|
assert ("session", {"session_id": andy.session_id, "action": "unlocked"}) in await _drain(andy.queue)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_locked_session_hides_only_itself() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
hub = events.EventHub()
|
||||||
|
uid = uuid.uuid4()
|
||||||
|
tablet = _sub(hub, "Wes Super", packet_id=7, user_id=uid)
|
||||||
|
_sub(hub, "Wes Super", packet_id=7, user_id=uid) # the same person on a second device
|
||||||
|
_session(hub, tablet, "locked")
|
||||||
|
assert _on(hub, 7) == [("Wes Super", "Supervisor", None)]
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoked_is_gone_at_once() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
hub = events.EventHub()
|
||||||
|
wes = _sub(hub, "Wes Super", packet_id=7)
|
||||||
|
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
|
||||||
|
await _drain(wes.queue)
|
||||||
|
_session(hub, andy, "revoked")
|
||||||
|
assert _names(hub) == ["Wes Super"] # before the stream has ended
|
||||||
|
got = await _drain(wes.queue)
|
||||||
|
assert got and got[-1][0] == "presence" and len(got[-1][1]["users"]) == 1
|
||||||
|
assert ("session", {"session_id": andy.session_id, "action": "revoked"}) in await _drain(andy.queue)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_forwards_lock_and_keeps_going() -> None:
|
||||||
|
async def run() -> list[str]:
|
||||||
|
hub = events.EventHub()
|
||||||
|
sub = _sub(hub, "Wes Super", screen="supervisor")
|
||||||
|
await _drain(sub.queue)
|
||||||
|
|
||||||
|
async def connected() -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
gen = events.stream(hub, sub, hello={"session_id": sub.session_id}, is_disconnected=connected, ping_seconds=5)
|
||||||
|
chunks = [await gen.__anext__()]
|
||||||
|
_session(hub, sub, "locked")
|
||||||
|
for _ in range(3):
|
||||||
|
chunks.append(await asyncio.wait_for(gen.__anext__(), 2))
|
||||||
|
if chunks[-1].startswith("event: session"):
|
||||||
|
break
|
||||||
|
_session(hub, sub, "revoked")
|
||||||
|
rest = [c async for c in gen]
|
||||||
|
assert hub.presence() == {"users": []}
|
||||||
|
return chunks + rest
|
||||||
|
|
||||||
|
chunks = asyncio.run(run())
|
||||||
|
assert chunks[0].startswith("event: hello\n")
|
||||||
|
names = [c.split("\n", 1)[0] for c in chunks[1:]]
|
||||||
|
assert "event: session" in names # the lock reached the locked session's own stream
|
||||||
|
assert any('"action":"locked"' in c for c in chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_presence_by_screen() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
hub = events.EventHub()
|
||||||
|
_sub(hub, "Wes Super", screen="supervisor")
|
||||||
|
_sub(hub, "Lou Lead", screen="supervisor")
|
||||||
|
_sub(hub, "Quinn QC", screen="qc", role="Q.C.")
|
||||||
|
locked = _sub(hub, "Andy Welder", screen="stall", role="Weld Stall")
|
||||||
|
_session(hub, locked, "locked")
|
||||||
|
by = hub.presence_by_screen()
|
||||||
|
assert sorted(u["display_name"] for u in by["supervisor"]) == ["Lou Lead", "Wes Super"]
|
||||||
|
assert [u["display_name"] for u in by["qc"]] == ["Quinn QC"]
|
||||||
|
assert not by.get("stall")
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ through the API
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
|
||||||
|
from packetstation.main import create_app
|
||||||
|
|
||||||
|
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _signed(client: TestClient, sam: str) -> tuple[TestClient, str]:
|
||||||
|
c = TestClient(client.app, headers=ORIGIN_HEADERS)
|
||||||
|
r = sign_in(c, sam)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return c, str(r.json()["session"]["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@needs_pg
|
||||||
|
def test_events_stream_carries_the_role(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""The stream body is replaced by one that answers the hub's presence and ends, so the request returns."""
|
||||||
|
from packetstation.api import events as events_api
|
||||||
|
|
||||||
|
async def snapshot(
|
||||||
|
hub: events.EventHub, sub: events.Subscriber, *, hello: dict[str, Any], **_: Any
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
try:
|
||||||
|
yield events.format_sse("presence", hub.presence())
|
||||||
|
finally:
|
||||||
|
hub.unsubscribe(sub)
|
||||||
|
|
||||||
|
monkeypatch.setattr(events_api, "stream", snapshot)
|
||||||
|
|
||||||
|
def users(c: TestClient, **params: Any) -> list[dict[str, Any]]:
|
||||||
|
r = c.get("/api/events", params=params)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
line = next(x for x in r.text.splitlines() if x.startswith("data: "))
|
||||||
|
out: list[dict[str, Any]] = json.loads(line[6:])["users"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
wes, _ = _signed(client, "sup.wes")
|
||||||
|
me = users(wes, screen="supervisor", packet_id=5)
|
||||||
|
assert [(u["display_name"], u["role"], u["screen"], u["packet_id"]) for u in me] == [
|
||||||
|
("Wes Super", "Supervisor", "supervisor", 5)
|
||||||
|
]
|
||||||
|
assert me[0]["since"]
|
||||||
|
# a screen the user does not have: no screen, the first role's label
|
||||||
|
other = users(wes, screen="qc")
|
||||||
|
assert [(u["role"], u["screen"]) for u in other] == [("Supervisor", None)]
|
||||||
|
for sam, screen, role in (
|
||||||
|
("pm.paula", "pm", "Project Mgmt"),
|
||||||
|
("weld.andy", "stall", "Weld Stall"),
|
||||||
|
("clerk.clara", "clerical", "Clerical"),
|
||||||
|
("wh.wade", "warehouse", "Warehouse"),
|
||||||
|
("qc.quinn", "qc", "Q.C."),
|
||||||
|
("admin.ann", "admin", "Admin"),
|
||||||
|
):
|
||||||
|
c, _ = _signed(client, sam)
|
||||||
|
assert [u["role"] for u in users(c, screen=screen)] == [role], sam
|
||||||
|
paula, _ = _signed(client, "pm.paula")
|
||||||
|
assert [u["role"] for u in users(paula)] == ["Project Mgmt"] # no screen: the primary role
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_session(q: asyncio.Queue[events.Item], action: str) -> dict[str, Any]:
|
||||||
|
while True:
|
||||||
|
name, data = await asyncio.wait_for(q.get(), timeout=10)
|
||||||
|
if name == "session" and data.get("action") == action:
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@needs_pg
|
||||||
|
def test_lock_and_unlock_publish_session_events(client: TestClient, pg_settings: Settings) -> None:
|
||||||
|
wes, sid = _signed(client, "sup.wes")
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
hub = events.EventHub(connect=lambda: db.connect_as("app", pg_settings, autocommit=True))
|
||||||
|
sub = _sub(hub, "Wes Super", screen="supervisor", packet_id=3, session_id=sid)
|
||||||
|
other = _sub(hub, "Andy Welder", screen="stall", packet_id=3, role="Weld Stall")
|
||||||
|
hub.start()
|
||||||
|
try:
|
||||||
|
for _ in range(100):
|
||||||
|
if hub.connected:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert hub.connected
|
||||||
|
r = await asyncio.to_thread(wes.post, "/api/auth/lock")
|
||||||
|
assert r.status_code == 204, r.text
|
||||||
|
data = await _wait_session(sub.queue, "locked")
|
||||||
|
assert data["session_id"] == sid
|
||||||
|
assert _names(hub) == ["Andy Welder"]
|
||||||
|
assert other.queue.qsize() >= 1 # the others got a presence update
|
||||||
|
r = await asyncio.to_thread(
|
||||||
|
wes.post, "/api/auth/unlock", json={"factor": "password", "secret": FAKE_PASSWORD}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
await _wait_session(sub.queue, "unlocked")
|
||||||
|
assert sorted(_names(hub)) == ["Andy Welder", "Wes Super"]
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(hub.stop)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
@needs_pg
|
||||||
|
def test_idle_lock_publishes(client: TestClient, pg_settings: Settings) -> None:
|
||||||
|
wes, sid = _signed(client, "sup.wes")
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
hub = events.EventHub(connect=lambda: db.connect_as("app", pg_settings, autocommit=True))
|
||||||
|
sub = _sub(hub, "Wes Super", session_id=sid)
|
||||||
|
hub.start()
|
||||||
|
try:
|
||||||
|
for _ in range(100):
|
||||||
|
if hub.connected:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert hub.connected
|
||||||
|
with db.connect_as("app", pg_settings, autocommit=True) as conn:
|
||||||
|
conn.execute("UPDATE sessions SET last_seen_at = now() - interval '5 minutes' WHERE id = %s", (sid,))
|
||||||
|
r = await asyncio.to_thread(wes.get, "/api/auth/me")
|
||||||
|
assert r.status_code == 200 and r.json()["session"]["locked"] is True, r.text
|
||||||
|
await _wait_session(sub.queue, "locked")
|
||||||
|
assert _names(hub) == []
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(hub.stop)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Phase 8: the find bar search (`q=`), one test per field of the vocabulary, on `GET /api/packets`.
|
||||||
|
|
||||||
|
Every word must match some field; a word matches by substring, case insensitive. Each distinctive value below is
|
||||||
|
on one packet only, so a search returns exactly the packets that carry it. The world is built once per module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pmhelpers import add_alias, make_project, make_unit, module_shop, quick_draft
|
||||||
|
from pshelpers import needs_pg
|
||||||
|
from shophelpers import Shop, ok, run, step
|
||||||
|
|
||||||
|
pytestmark = needs_pg
|
||||||
|
|
||||||
|
LONG = "Q" * 60 # a title of exactly 60 characters
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SearchWorld:
|
||||||
|
shop: Shop
|
||||||
|
k: dict[str, int] = field(default_factory=dict)
|
||||||
|
every: set[int] = field(default_factory=set)
|
||||||
|
|
||||||
|
def ids(self, *keys: str) -> set[int]:
|
||||||
|
return {self.k[x] for x in keys}
|
||||||
|
|
||||||
|
|
||||||
|
def make(
|
||||||
|
shop: Shop,
|
||||||
|
w: SearchWorld,
|
||||||
|
key: str,
|
||||||
|
unit_id: int,
|
||||||
|
title: str,
|
||||||
|
*,
|
||||||
|
ready_dept: str | None = None,
|
||||||
|
**over: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
pkt = quick_draft(shop, unit_id, title, n=len(w.k) + 1, send=ready_dept, **over)
|
||||||
|
w.k[key] = int(pkt["id"])
|
||||||
|
w.every.add(int(pkt["id"]))
|
||||||
|
return pkt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[SearchWorld]:
|
||||||
|
with module_shop(tmp_path_factory.mktemp("search")) as shop:
|
||||||
|
s = shop.settings
|
||||||
|
w = SearchWorld(shop)
|
||||||
|
p1 = make_project(s, "L489", "BPX separator", customer="BPX Energy", po="PO-4471")
|
||||||
|
add_alias(s, p1, "OLD489X")
|
||||||
|
u52, u53, u54 = (make_unit(s, p1, lot) for lot in ("052", "053", "054"))
|
||||||
|
p2 = make_project(s, "L512", "Pump skid", customer="Vortex Oil", po="PO-9")
|
||||||
|
u2 = make_unit(s, p2, "001")
|
||||||
|
|
||||||
|
make(shop, w, "title", u52, "Inlet spool", drawing="D-48810", rev="B")
|
||||||
|
make(shop, w, "op", u53, "Bravo spool", op="HYDRO-TEST")
|
||||||
|
make(shop, w, "wps", u53, "Charlie spool", wps="WPS-77X")
|
||||||
|
make(shop, w, "cust", u54, "Delta spool", customer="Acme Gas")
|
||||||
|
make(
|
||||||
|
shop,
|
||||||
|
w,
|
||||||
|
"wo",
|
||||||
|
u54,
|
||||||
|
"Echo spool",
|
||||||
|
work_orders=[{"wo_base": "5ZZQ", "lot": "007", "split": "0", "sub": "0", "qty": 1, "target_hrs": 1}],
|
||||||
|
)
|
||||||
|
make(
|
||||||
|
shop,
|
||||||
|
w,
|
||||||
|
"heat",
|
||||||
|
u52,
|
||||||
|
"Foxtrot spool",
|
||||||
|
bom=[
|
||||||
|
{"item": 1, "qty": 1, "part_id": "FLG-9876", "description": "Weld neck flange"},
|
||||||
|
{"item": 2, "qty": 1, "part_id": "FLG-9877", "description": "Blind flange"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
make(
|
||||||
|
shop,
|
||||||
|
w,
|
||||||
|
"mat",
|
||||||
|
u53,
|
||||||
|
"Golf spool",
|
||||||
|
bom=[
|
||||||
|
{"item": 1, "qty": 2, "part_id": "GSK-1", "description": "Spiral wound gasket", "material": "A182-F316"}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
make(shop, w, "p2", u2, "Hotel spool")
|
||||||
|
make(shop, w, "asg", u2, "India spool", ready_dept="03")
|
||||||
|
make(shop, w, "co", u52, "Juliet spool")
|
||||||
|
make(shop, w, "dept", u2, "Kilo spool", ready_dept="06")
|
||||||
|
make(shop, w, "flag", u54, "Lima spool")
|
||||||
|
make(shop, w, "long", u54, LONG)
|
||||||
|
|
||||||
|
# the assigned welder and the stamp
|
||||||
|
asg = ok(shop("sup.wes").get(f"/api/packets/{w.k['asg']}"))
|
||||||
|
ok(
|
||||||
|
step(
|
||||||
|
shop("sup.wes"), asg, "assign", station_id=shop.stall("03", "Stall 2"), welder_id=shop.uid("weld.andy")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
run(s, "UPDATE packets SET welder_stamp = 'A7Q' WHERE id = %s", (w.k["asg"],))
|
||||||
|
run(s, "UPDATE users SET welder_stamp = 'A7Q' WHERE sam_account = 'weld.andy'")
|
||||||
|
# two BOM lines with one heat and two welds with one stamp: one packet, never two rows
|
||||||
|
run(s, "UPDATE bom_lines SET heat = 'HX44921' WHERE packet_id = %s", (w.k["heat"],))
|
||||||
|
for no in (1, 2):
|
||||||
|
run(
|
||||||
|
s,
|
||||||
|
"INSERT INTO welds (packet_id, weld_no, items, type, stamp, welder_id) VALUES (%s, %s, '{1}',"
|
||||||
|
" 'Fillet', 'ZQ9', (SELECT id FROM users WHERE sam_account = 'lead.lou'))",
|
||||||
|
(w.k["heat"], no),
|
||||||
|
)
|
||||||
|
# a change order with an ECN
|
||||||
|
run(
|
||||||
|
s,
|
||||||
|
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, ecn, reason)"
|
||||||
|
" VALUES (%s, 47, 'manual', 'approved', true, 'ECN-7781', 'Nozzle moved')",
|
||||||
|
(w.k["co"],),
|
||||||
|
)
|
||||||
|
# a VISUAL draft with a flag kind
|
||||||
|
run(
|
||||||
|
s,
|
||||||
|
"UPDATE packets SET source_kind = 'visual', flags = '{rev_mismatch}', drawing_state = 'rev_mismatch'"
|
||||||
|
" WHERE id = %s",
|
||||||
|
(w.k["flag"],),
|
||||||
|
)
|
||||||
|
yield w
|
||||||
|
|
||||||
|
|
||||||
|
def found(w: SearchWorld, q: str, sam: str = "sup.wes") -> list[int]:
|
||||||
|
r = w.shop(sam).get("/api/packets", params={"q": q})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return [int(c["id"]) for c in r.json()["packets"]]
|
||||||
|
|
||||||
|
|
||||||
|
def hits(w: SearchWorld, q: str) -> set[int]:
|
||||||
|
got = found(w, q)
|
||||||
|
assert len(got) == len(set(got)), ("duplicate rows", q, got)
|
||||||
|
return set(got)
|
||||||
|
|
||||||
|
|
||||||
|
P1 = ("title", "op", "wps", "cust", "wo", "heat", "mat", "co", "flag", "long")
|
||||||
|
P2 = ("p2", "asg", "dept")
|
||||||
|
DRAFTS = tuple(k for k in (*P1, *P2) if k not in ("asg", "dept"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("q", "keys"),
|
||||||
|
[
|
||||||
|
("L512", P2), # project number
|
||||||
|
("pump", P2), # project name
|
||||||
|
("L512001", P2), # unit number
|
||||||
|
("L5121", P2), # unit number with the lot's zeros stripped (L512 || 1)
|
||||||
|
("L48952", ("title", "heat", "co")), # the same for lot 052
|
||||||
|
("lot 52", ("title", "heat", "co")), # lot 052 by its number
|
||||||
|
("HYDRO-TEST", ("op",)), # operation
|
||||||
|
("D-48810", ("title",)), # drawing
|
||||||
|
("D-48810 rev B", ("title",)), # drawing and rev
|
||||||
|
("WPS-77X", ("wps",)), # WPS
|
||||||
|
("acme", ("cust",)), # packet customer
|
||||||
|
("vortex", P2), # project customer
|
||||||
|
("4471", P1), # project PO
|
||||||
|
("OLD489X", P1), # project alias
|
||||||
|
("5zzq", ("wo",)), # work order base
|
||||||
|
("5ZZQ/007", ("wo",)), # work order and lot
|
||||||
|
("hx449", ("heat",)), # BOM heat
|
||||||
|
("FLG-9876", ("heat",)), # BOM part id
|
||||||
|
("spiral", ("mat",)), # BOM description
|
||||||
|
("A182-F316", ("mat",)), # BOM material
|
||||||
|
("andy", ("asg",)), # assigned welder's name
|
||||||
|
("A7Q", ("asg",)), # assigned welder's stamp
|
||||||
|
("ZQ9", ("heat",)), # weld stamp
|
||||||
|
("lou", ("heat",)), # the welder named on a weld
|
||||||
|
("CO-47", ("co",)), # change order number
|
||||||
|
("ECN-7781", ("co",)), # ECN
|
||||||
|
("stall", ("asg",)), # station name
|
||||||
|
("bldg", ("dept",)), # department name
|
||||||
|
("sent", ("asg",)), # status label: assigned is "Sent to station"
|
||||||
|
("rev_mismatch", ("flag",)), # flag kind
|
||||||
|
("flagged", DRAFTS), # every flagged draft (hand drafts and the flagged VISUAL draft)
|
||||||
|
("inlet", ("title",)), # title
|
||||||
|
("INLET", ("title",)), # case insensitive
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_search_field(world: SearchWorld, q: str, keys: tuple[str, ...]) -> None:
|
||||||
|
assert hits(world, q) == world.ids(*keys)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lot_and_rev_alone_match_everything(world: SearchWorld) -> None:
|
||||||
|
assert hits(world, "lot") == world.every
|
||||||
|
assert hits(world, "rev") == world.every
|
||||||
|
assert hits(world, "lot 052") == world.ids("title", "heat", "co")
|
||||||
|
|
||||||
|
|
||||||
|
def test_several_words_narrow(world: SearchWorld) -> None:
|
||||||
|
assert hits(world, "L489") == world.ids(*P1)
|
||||||
|
assert hits(world, "L489 spool") == world.ids(*(k for k in P1 if k != "long"))
|
||||||
|
assert hits(world, "L489 52") == world.ids("title", "heat", "co")
|
||||||
|
assert hits(world, "L489 52 inlet") == world.ids("title")
|
||||||
|
assert hits(world, "L512 HYDRO-TEST") == set()
|
||||||
|
assert hits(world, " inlet L489 ") == world.ids("title")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_duplicates_from_child_rows(world: SearchWorld) -> None:
|
||||||
|
for q in ("hx449", "ZQ9", "FLG", "lou", "flange"):
|
||||||
|
got = found(world, q)
|
||||||
|
assert got == [world.k["heat"]], (q, got)
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_word_is_cut_at_60(world: SearchWorld) -> None:
|
||||||
|
assert hits(world, LONG) == world.ids("long")
|
||||||
|
assert hits(world, LONG + "ZZZZ") == world.ids("long") # cut to the first 60 characters
|
||||||
|
assert hits(world, "Q" * 61) == world.ids("long")
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_eight_words_count(world: SearchWorld) -> None:
|
||||||
|
assert hits(world, " ".join(["inlet"] * 8)) == world.ids("title")
|
||||||
|
assert hits(world, " ".join(["lot"] * 8 + ["inlet"])) == world.every
|
||||||
|
|
||||||
|
|
||||||
|
def test_like_wildcards_are_literal(world: SearchWorld) -> None:
|
||||||
|
assert hits(world, "%") == set()
|
||||||
|
assert hits(world, "_") == world.ids("flag") # only the flag kind has an underscore
|
||||||
Reference in New Issue
Block a user