Initial commit: PESCO NCR system
Complete Non-Conformance Report system replacing the PowerApps/SharePoint prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend, React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS, group-gated), Microsoft Graph delegated Mail.Send notifications (OBO), six-stage workflow state machine with server-side enforcement, atomic NCR-YYYY-NNNN numbering, attachments with camera capture, immutable field-level audit trail, admin reopen, reports + CSV export, WeasyPrint PDF traveler, Power BI reporting views + read-only DB user, documented VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose deployment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "frontend-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"cwd": "frontend",
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
78
.env.example
Normal file
78
.env.example
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# PESCO NCR — environment configuration
|
||||||
|
# Copy to `.env` and fill in the values marked __LIKE_THIS__.
|
||||||
|
# Values with defaults can be left as-is for a local/dev deployment.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── General ─────────────────────────────────────────────────────────────────
|
||||||
|
# Public URL users open in the browser. Used to build links inside
|
||||||
|
# notification emails, so it must be reachable from user machines.
|
||||||
|
APP_BASE_URL=http://localhost:8080
|
||||||
|
# Host port the frontend (nginx) is published on.
|
||||||
|
HTTP_PORT=8080
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# ── Authentication (Microsoft Entra ID) ─────────────────────────────────────
|
||||||
|
# AUTH_MODE=entra → real Entra ID sign-in (production).
|
||||||
|
# AUTH_MODE=dev → NO real auth; the app trusts an X-Dev-User header and the
|
||||||
|
# UI shows a user switcher. For local development/demo ONLY.
|
||||||
|
AUTH_MODE=entra
|
||||||
|
|
||||||
|
# From your Entra app registration (see README "Entra ID setup").
|
||||||
|
ENTRA_TENANT_ID=__YOUR_ENTRA_TENANT_ID__
|
||||||
|
ENTRA_CLIENT_ID=__YOUR_ENTRA_APP_CLIENT_ID__
|
||||||
|
# Client secret is required for the On-Behalf-Of (OBO) exchange the API uses
|
||||||
|
# to call Microsoft Graph (delegated Mail.Send) and for group-overage checks.
|
||||||
|
ENTRA_CLIENT_SECRET=__YOUR_ENTRA_APP_CLIENT_SECRET__
|
||||||
|
|
||||||
|
# Object ID of the security group that gates access to the app (e.g. NCR-Users).
|
||||||
|
# Leave empty to disable the group check (not recommended in production).
|
||||||
|
ENTRA_ALLOWED_GROUP_ID=__NCR_USERS_GROUP_OBJECT_ID__
|
||||||
|
|
||||||
|
# Expected audience of API access tokens. Leave empty to accept the default
|
||||||
|
# (api://<ENTRA_CLIENT_ID> and the bare client id).
|
||||||
|
ENTRA_API_AUDIENCE=
|
||||||
|
|
||||||
|
# Scope the frontend requests for the API. Leave empty for the default
|
||||||
|
# api://<ENTRA_CLIENT_ID>/access_as_user
|
||||||
|
ENTRA_API_SCOPE=
|
||||||
|
|
||||||
|
# Comma-separated emails that are auto-granted the Admin role on first login.
|
||||||
|
# Needed to bootstrap the first administrator.
|
||||||
|
INITIAL_ADMIN_EMAILS=spencerm@pescoinc.biz
|
||||||
|
|
||||||
|
# ── MySQL ───────────────────────────────────────────────────────────────────
|
||||||
|
MYSQL_HOST=mysql
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_DATABASE=pesco_ncr
|
||||||
|
MYSQL_USER=ncr_app
|
||||||
|
MYSQL_PASSWORD=__CHOOSE_A_STRONG_APP_PASSWORD__
|
||||||
|
MYSQL_ROOT_PASSWORD=__CHOOSE_A_STRONG_ROOT_PASSWORD__
|
||||||
|
# Host port MySQL is published on (for the Power BI gateway). Firewall this.
|
||||||
|
MYSQL_PUBLISHED_PORT=3306
|
||||||
|
# Password for the read-only reporting account (created on first startup).
|
||||||
|
POWERBI_RO_PASSWORD=__CHOOSE_A_STRONG_POWERBI_PASSWORD__
|
||||||
|
|
||||||
|
# ── Attachments ─────────────────────────────────────────────────────────────
|
||||||
|
# Stored on the named docker volume `attachments_data`, mounted at this path.
|
||||||
|
ATTACHMENTS_DIR=/data/attachments
|
||||||
|
MAX_UPLOAD_MB=25
|
||||||
|
|
||||||
|
# ── Email notifications (Microsoft Graph, delegated Mail.Send) ──────────────
|
||||||
|
# Runtime on/off lives in the Admin screen; this is only the initial default.
|
||||||
|
NOTIFICATIONS_ENABLED_DEFAULT=true
|
||||||
|
|
||||||
|
# ── Job lookup provider (future Infor VISUAL ERP integration) ───────────────
|
||||||
|
# null → job numbers accepted as free text (current behavior)
|
||||||
|
# visual → VisualJobLookupService (stub today; see backend/app/services/job_lookup.py)
|
||||||
|
JOB_LOOKUP_PROVIDER=null
|
||||||
|
VISUAL_DB_HOST=
|
||||||
|
VISUAL_DB_PORT=1433
|
||||||
|
VISUAL_DB_NAME=
|
||||||
|
VISUAL_DB_USER=
|
||||||
|
VISUAL_DB_PASSWORD=
|
||||||
|
VISUAL_SITE_ID=
|
||||||
|
|
||||||
|
# ── Seed data ───────────────────────────────────────────────────────────────
|
||||||
|
# When `python -m app.seed` runs: also create demo users + sample NCRs.
|
||||||
|
SEED_DEMO_DATA=true
|
||||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
*.egg-info/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
|
||||||
|
# Node / frontend
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Data
|
||||||
|
attachments/
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# OS / editors
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
308
README.md
Normal file
308
README.md
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
# PESCO NCR — Non-Conformance Report System
|
||||||
|
|
||||||
|
A web-based Non-Conformance Report (NCR / "QN") system for PESCO, replacing the
|
||||||
|
PowerApps/SharePoint prototype. Shop-floor and office users log nonconformance
|
||||||
|
issues on jobs, route them through disposition review, operations rework, QC
|
||||||
|
inspection, and costing, then close them — with full audit history, email
|
||||||
|
notifications, reporting, and Power BI access.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Quick start (local demo, no Entra required)](#quick-start-local-demo-no-entra-required)
|
||||||
|
- [Production setup](#production-setup)
|
||||||
|
- [Entra ID app registration](#entra-id-app-registration)
|
||||||
|
- [Email notifications (delegated Graph send)](#email-notifications-delegated-graph-send)
|
||||||
|
- [Workflow & roles](#workflow--roles)
|
||||||
|
- [Migrations & seed data](#migrations--seed-data)
|
||||||
|
- [Power BI](#power-bi)
|
||||||
|
- [Future VISUAL ERP integration](#future-visual-erp-integration)
|
||||||
|
- [Backend tests](#backend-tests)
|
||||||
|
- [Development outside Docker](#development-outside-docker)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────┐ HTTPS ┌─────────────────────┐ ┌──────────────┐
|
||||||
|
│ Browser │ ─────────▶ │ frontend (nginx) │ │ Entra ID │
|
||||||
|
│ React SPA │ │ - serves built SPA │ │ (OIDC/JWKS) │
|
||||||
|
│ MSAL │ │ - proxies /api ────┼──┐ └──────▲───────┘
|
||||||
|
└────────────┘ └─────────────────────┘ │ │ token
|
||||||
|
▼ │ validation,
|
||||||
|
┌─────────────────────────────┐ │ OBO exchange
|
||||||
|
│ api (FastAPI, SQLAlchemy 2) │ ──────┘
|
||||||
|
│ - state machine, RBAC │ ──▶ Microsoft Graph
|
||||||
|
│ - audit trail, numbering │ (delegated
|
||||||
|
│ - WeasyPrint PDF, reports │ Mail.Send)
|
||||||
|
└───────┬──────────────┬───────┘
|
||||||
|
│ │
|
||||||
|
┌─────────▼───────┐ ┌───▼──────────────┐
|
||||||
|
│ MySQL 8 (volume)│ │ attachments │
|
||||||
|
│ + reporting │ │ (named volume) │
|
||||||
|
│ views for BI │ └──────────────────┘
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Piece | Tech |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Python 3.12, FastAPI, SQLAlchemy 2 (async, aiomysql), Alembic, Pydantic v2 |
|
||||||
|
| Frontend | React 18 + Vite + TypeScript, MUI, React Router, TanStack Query, TipTap, Recharts |
|
||||||
|
| Auth | Entra ID (OIDC) — `@azure/msal-react` in the SPA, `python-jose`/JWKS validation in the API |
|
||||||
|
| Email | Microsoft Graph **delegated** `Mail.Send` via the On-Behalf-Of flow |
|
||||||
|
| PDF | WeasyPrint (HTML → PDF) |
|
||||||
|
| Database | MySQL 8 (utf8mb4), named volume; SQLite used only by the test suite |
|
||||||
|
|
||||||
|
Key backend modules:
|
||||||
|
|
||||||
|
- `backend/app/domain.py` — roles, stages, allowed transitions
|
||||||
|
- `backend/app/services/workflow.py` — the state machine (all transitions validated server-side)
|
||||||
|
- `backend/app/services/numbering.py` — atomic `NCR-YYYY-NNNN` allocation (per-year row lock)
|
||||||
|
- `backend/app/services/notifications.py` — fault-tolerant Graph notifications
|
||||||
|
- `backend/app/services/job_lookup.py` — `JobLookupService` seam for the future VISUAL integration
|
||||||
|
- `backend/app/routers/ncrs.py` — NCR endpoints (create, queues, stage actions, attachments, audit, CSV, PDF)
|
||||||
|
|
||||||
|
API docs (OpenAPI/Swagger) are served at **`/api/docs`**.
|
||||||
|
|
||||||
|
## Quick start (local demo, no Entra required)
|
||||||
|
|
||||||
|
Runs the full stack with **dev auth** (a user switcher instead of Entra —
|
||||||
|
never use outside a lab):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# In .env set:
|
||||||
|
# AUTH_MODE=dev
|
||||||
|
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD / POWERBI_RO_PASSWORD → anything
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# seed departments/categories, demo users, and sample NCRs (SEED_DEMO_DATA=true)
|
||||||
|
docker compose exec api python -m app.seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://localhost:8080**. The switcher in the top bar signs you in as any
|
||||||
|
demo user (`admin@`, `dispo@`, `second@`, `ops@`, `qc@`, `cost@`,
|
||||||
|
`req@pescoinc.biz`) so you can walk an NCR through the whole workflow.
|
||||||
|
|
||||||
|
## Production setup
|
||||||
|
|
||||||
|
1. Complete the [Entra ID app registration](#entra-id-app-registration) below.
|
||||||
|
2. `cp .env.example .env` and fill in everything marked `__LIKE_THIS__`:
|
||||||
|
- `AUTH_MODE=entra`
|
||||||
|
- `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET`
|
||||||
|
- `ENTRA_ALLOWED_GROUP_ID` — object ID of the `NCR-Users` security group
|
||||||
|
- `INITIAL_ADMIN_EMAILS` — who gets the Admin role on first sign-in
|
||||||
|
- `APP_BASE_URL` — the URL users browse to (used in email links)
|
||||||
|
- strong MySQL + Power BI passwords
|
||||||
|
3. `docker compose up -d --build` — migrations run automatically on API start.
|
||||||
|
4. `docker compose exec api python -m app.seed` — seeds departments/categories
|
||||||
|
(set `SEED_DEMO_DATA=false` first to skip demo users/NCRs).
|
||||||
|
5. Sign in with an `INITIAL_ADMIN_EMAILS` account, open **Admin → Users & Roles**,
|
||||||
|
and assign Disposition Authority / Operations / QC Inspector / Costing roles.
|
||||||
|
|
||||||
|
Put TLS in front of the `frontend` service (reverse proxy or load balancer) and
|
||||||
|
update the Entra redirect URI + `APP_BASE_URL` to the HTTPS URL.
|
||||||
|
|
||||||
|
## Entra ID app registration
|
||||||
|
|
||||||
|
One app registration serves both the SPA and the API.
|
||||||
|
|
||||||
|
1. **Create the registration** — Azure portal → Entra ID → App registrations →
|
||||||
|
*New registration*. Name: `PESCO NCR`. Supported account types: *single
|
||||||
|
tenant*.
|
||||||
|
2. **SPA redirect URIs** — Authentication → *Add a platform* →
|
||||||
|
**Single-page application** → add:
|
||||||
|
- `http://localhost:8080` (or your `APP_BASE_URL`)
|
||||||
|
- your production URL, e.g. `https://ncr.pescoinc.biz`
|
||||||
|
3. **Expose the API** — Expose an API → *Set* the Application ID URI to the
|
||||||
|
default `api://<client-id>` → *Add a scope*:
|
||||||
|
- Scope name: `access_as_user`
|
||||||
|
- Who can consent: Admins and users
|
||||||
|
- Display name/description: "Access the PESCO NCR API as the signed-in user"
|
||||||
|
4. **API permissions** — *Add a permission* → Microsoft Graph → **Delegated**:
|
||||||
|
- `User.Read` (usually present already)
|
||||||
|
- `Mail.Send` — required for stage-transition emails
|
||||||
|
- `GroupMember.Read.All` — optional; only needed for the group-overage
|
||||||
|
fallback (users in >200 groups)
|
||||||
|
Then click **Grant admin consent**.
|
||||||
|
5. **Client secret** — Certificates & secrets → *New client secret* → put the
|
||||||
|
value in `ENTRA_CLIENT_SECRET`. (Used by the API for the OBO exchange and
|
||||||
|
overage checks; the SPA never sees it.)
|
||||||
|
6. **Groups claim** — Token configuration → *Add groups claim* → select
|
||||||
|
**Security groups** (for both ID and access tokens). If your users belong to
|
||||||
|
many groups, prefer **Groups assigned to the application** and assign
|
||||||
|
`NCR-Users` to the app (Enterprise application → Users and groups) to avoid
|
||||||
|
claim overage.
|
||||||
|
7. **Access-token version** — the API accepts both v1 and v2 issuers. For
|
||||||
|
clean v2 tokens set `"accessTokenAcceptedVersion": 2` in the app manifest.
|
||||||
|
8. **Front-door group** — create (or reuse) a security group such as
|
||||||
|
`NCR-Users`, add everyone who may use the app, and put its **object ID** in
|
||||||
|
`ENTRA_ALLOWED_GROUP_ID`. Users outside the group get "Access denied" even
|
||||||
|
with a valid token.
|
||||||
|
|
||||||
|
Users are auto-provisioned in the local DB on first sign-in (OID, name, email,
|
||||||
|
employee ID when present in the token) with the default **Requester** role.
|
||||||
|
|
||||||
|
## Email notifications (delegated Graph send)
|
||||||
|
|
||||||
|
Notifications are sent **from the mailbox of the user who performed the
|
||||||
|
action**, using the **On-Behalf-Of (OBO) flow** — chosen over passing
|
||||||
|
frontend-acquired Graph tokens because it keeps Graph scopes and token plumbing
|
||||||
|
entirely server-side: the SPA only ever requests the API scope, and the API
|
||||||
|
exchanges the incoming access token for a delegated Graph token when it needs
|
||||||
|
to send mail (`backend/app/services/graph.py`).
|
||||||
|
|
||||||
|
| Event | Recipients | Sent from |
|
||||||
|
|---|---|---|
|
||||||
|
| New request submitted | selected Disposition Authority | requester |
|
||||||
|
| Secondary review assigned | "Notify These People" | initial reviewer |
|
||||||
|
| Released to Operations | all Operations users | releasing reviewer |
|
||||||
|
| Operations complete | all QC Inspectors | operations user |
|
||||||
|
| QC closed | all Costing users | QC inspector |
|
||||||
|
| NCR closed | original requester | costing user |
|
||||||
|
| Admin reopen | owners of the target stage + requester | admin |
|
||||||
|
|
||||||
|
Fault tolerance: a Graph failure **never blocks a workflow transition** — the
|
||||||
|
transition is already committed; the failure is logged and returned in the
|
||||||
|
response `warnings` array, which the UI shows as a toast. The Admin → Settings
|
||||||
|
screen has a global on/off toggle (handy during testing).
|
||||||
|
|
||||||
|
## Workflow & roles
|
||||||
|
|
||||||
|
Stages (enforced server-side; invalid transitions are rejected with HTTP 409):
|
||||||
|
|
||||||
|
```
|
||||||
|
New Request ──(initial disposition)──┬── needs secondary review ──▶ Secondary Disposition ─┐
|
||||||
|
└───────────── no ──────────────────▶ Operations ◀────┘
|
||||||
|
Operations ─▶ QC Inspection ─▶ Costing ─▶ Closed (locked; Admin-only reopen with reason)
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- "Initial Disposition" is the review a **Disposition Authority** performs on
|
||||||
|
an NCR sitting in the *New Request* queue (QC authority, work order,
|
||||||
|
rich-text disposition notes, secondary-review decision).
|
||||||
|
- **Secondary Disposition** is visible only to the assigned "Notify These
|
||||||
|
People" users (their personal queue) and Admins; they may update disposition
|
||||||
|
fields and release to Operations.
|
||||||
|
- **QC Inspection** can be saved repeatedly until *QC Closed* advances it.
|
||||||
|
- Saving **Costing** (Labor/Material/Service/Other) closes the NCR. Closed
|
||||||
|
NCRs are fully read-only — including attachments — until an Admin reopens
|
||||||
|
them (required reason, recorded in the audit trail).
|
||||||
|
- Every stage change writes a `stage_transitions` row (timestamp + acting
|
||||||
|
user) — the basis for the aging and cycle-time reports — and every field
|
||||||
|
change writes an immutable `audit_log` row (before/after values). The app
|
||||||
|
exposes no way to edit or delete audit rows.
|
||||||
|
|
||||||
|
Roles (assigned in **Admin → Users & Roles**; a user may hold several):
|
||||||
|
Requester (default), Disposition Authority, Secondary Disposition Authority,
|
||||||
|
Operations, QC Inspector, Costing, Admin.
|
||||||
|
|
||||||
|
## Migrations & seed data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# run migrations manually (they also run on every api container start)
|
||||||
|
docker compose exec api alembic upgrade head
|
||||||
|
|
||||||
|
# seed lookups (+ demo data when SEED_DEMO_DATA=true)
|
||||||
|
docker compose exec api python -m app.seed
|
||||||
|
|
||||||
|
# create a new migration after model changes
|
||||||
|
docker compose exec api alembic revision --autogenerate -m "describe change"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Power BI
|
||||||
|
|
||||||
|
The schema ships three **read-only flattened views** for external reporting:
|
||||||
|
|
||||||
|
| View | Grain |
|
||||||
|
|---|---|
|
||||||
|
| `vw_ncr_full` | one row per NCR — all stage data, costs, computed `total_cost`, `days_in_stage`, ERP enrichment |
|
||||||
|
| `vw_ncr_stage_history` | one row per stage transition (for cycle-time analysis) |
|
||||||
|
| `vw_ncr_costs` | one row per costed NCR (cost of nonconformance) |
|
||||||
|
|
||||||
|
A dedicated MySQL account **`powerbi_ro`** is created on first startup
|
||||||
|
(`db/init/01-powerbi-user.sh`) with `SELECT` on exactly those views and nothing
|
||||||
|
else. If your MySQL volume was initialized before you set
|
||||||
|
`POWERBI_RO_PASSWORD`, run `scripts/powerbi_grants.sql` (instructions inside).
|
||||||
|
|
||||||
|
Pointing the gateway at it:
|
||||||
|
|
||||||
|
1. MySQL is published on `MYSQL_PUBLISHED_PORT` (default 3306). Firewall it so
|
||||||
|
only the Power BI gateway host can reach it.
|
||||||
|
2. On the gateway machine install the MySQL .NET connector
|
||||||
|
(Connector/NET 8.x — required for `caching_sha2_password`).
|
||||||
|
3. In Power BI Desktop: *Get data → MySQL database* → server
|
||||||
|
`<docker-host>:3306`, database `pesco_ncr`, user `powerbi_ro`.
|
||||||
|
4. Import (or DirectQuery) the three `vw_*` views; schedule refresh through the
|
||||||
|
gateway.
|
||||||
|
|
||||||
|
## Future VISUAL ERP integration
|
||||||
|
|
||||||
|
Job Number is free text today. The integration seam is already in place:
|
||||||
|
|
||||||
|
- `backend/app/services/job_lookup.py` defines the `JobLookupService`
|
||||||
|
protocol. The default **`NullJobLookupService`** returns no enrichment.
|
||||||
|
**`VisualJobLookupService`** is a documented stub containing the verified
|
||||||
|
VISUAL 10 query plan (WORK_ORDER composite key `TYPE/BASE_ID/LOT_ID/
|
||||||
|
SPLIT_ID/SUB_ID`, PART join, and the DEMAND_SUPPLY_LINK →
|
||||||
|
CUST_ORDER_LINE → CUSTOMER_ORDER → CUSTOMER customer linkage).
|
||||||
|
- The schema stores the job number as entered plus a nullable `job_info` row
|
||||||
|
(part, description, customer, WO status) any provider can populate.
|
||||||
|
- The SPA's job-number field already calls `GET /api/jobs/{job}/lookup` while
|
||||||
|
typing and displays whatever enrichment returns — validation/autocomplete
|
||||||
|
light up without a redesign.
|
||||||
|
|
||||||
|
To enable later: implement the stub (read-only SQL Server access — never write
|
||||||
|
to VISUAL tables), set `JOB_LOOKUP_PROVIDER=visual` plus the `VISUAL_DB_*`
|
||||||
|
variables, and restart the API.
|
||||||
|
|
||||||
|
## Backend tests
|
||||||
|
|
||||||
|
Covers the state machine (happy paths, invalid transitions, closure locking,
|
||||||
|
reopen), per-stage permission enforcement, NCR numbering (format, year
|
||||||
|
rollover, 12-way concurrent submission), attachments, and rich-text
|
||||||
|
sanitization.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
|
||||||
|
.venv/bin/pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests run against SQLite by default (same models/state machine/numbering code
|
||||||
|
paths); to exercise real MySQL locking:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL="mysql+aiomysql://user:pass@host/pesco_ncr_test?charset=utf8mb4" .venv/bin/pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development outside Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API (SQLite works fine for dev; export the vars or put them in backend/.env)
|
||||||
|
cd backend
|
||||||
|
export DATABASE_URL="sqlite+aiosqlite:///dev.db" AUTH_MODE=dev ATTACHMENTS_DIR=./attachments
|
||||||
|
.venv/bin/python -m app.dev_init # create tables (models → SQLite)
|
||||||
|
SEED_DEMO_DATA=true .venv/bin/python -m app.seed # demo users + sample NCRs
|
||||||
|
.venv/bin/uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
|
# SPA (proxies /api to :8000; public/config.js defaults to dev auth)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: generating PDFs locally requires WeasyPrint's system libraries (Pango/
|
||||||
|
Cairo — `brew install pango` on macOS). The Docker image includes them.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause / fix |
|
||||||
|
|---|---|
|
||||||
|
| "Access token has no groups claim" | Add the groups claim in Token configuration (step 6 above). |
|
||||||
|
| "Could not verify group membership (group overage)" | Grant delegated `GroupMember.Read.All` + admin consent, or scope the group claim to "Groups assigned to the application". |
|
||||||
|
| Notification warning "OBO token exchange failed" | Check `ENTRA_CLIENT_SECRET`, and that `Mail.Send` has admin consent. |
|
||||||
|
| `api` container restarts at boot | MySQL still initializing; the entrypoint retries migrations 12×. Check `docker compose logs mysql`. |
|
||||||
|
| Power BI can't authenticate | Update Connector/NET (needs `caching_sha2_password`), verify the `powerbi_ro` grants (`scripts/powerbi_grants.sql`). |
|
||||||
|
| Uploads fail at ~25 MB | Raise `MAX_UPLOAD_MB` (API) — nginx `client_max_body_size` is 50 MB in `frontend/nginx.conf`. |
|
||||||
9
backend/.dockerignore
Normal file
9
backend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
.pytest_cache
|
||||||
|
tests
|
||||||
|
dev.db
|
||||||
|
attachments
|
||||||
|
.env
|
||||||
31
backend/Dockerfile
Normal file
31
backend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
# WeasyPrint runtime libraries (Pango/Cairo) + curl for the container healthcheck.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libgdk-pixbuf-2.0-0 \
|
||||||
|
libffi8 \
|
||||||
|
shared-mime-info \
|
||||||
|
fonts-dejavu-core \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /srv
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
COPY alembic.ini .
|
||||||
|
COPY alembic ./alembic
|
||||||
|
COPY app ./app
|
||||||
|
COPY entrypoint.sh .
|
||||||
|
RUN chmod +x entrypoint.sh && mkdir -p /data/attachments
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
38
backend/alembic.ini
Normal file
38
backend/alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
# URL is injected from app settings in alembic/env.py
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
43
backend/alembic/env.py
Normal file
43
backend/alembic/env.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", get_settings().sync_database_url)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=config.get_main_option("sqlalchemy.url"),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
23
backend/alembic/script.py.mako
Normal file
23
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
225
backend/alembic/versions/0001_initial_schema.py
Normal file
225
backend/alembic/versions/0001_initial_schema.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-13
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
MYSQL = {"mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("entra_oid", sa.String(64), nullable=True, unique=True),
|
||||||
|
sa.Column("email", sa.String(255), nullable=False, unique=True, index=True),
|
||||||
|
sa.Column("display_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("employee_id", sa.String(64), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"user_roles",
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column("role", sa.String(40), primary_key=True),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"departments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False, unique=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"deviation_categories",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False, unique=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ncr_sequences",
|
||||||
|
sa.Column("year", sa.Integer(), primary_key=True, autoincrement=False),
|
||||||
|
sa.Column("last_seq", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ncrs",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("ncr_number", sa.String(20), nullable=False),
|
||||||
|
sa.Column("ncr_year", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("ncr_seq", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("job_number", sa.String(100), nullable=False),
|
||||||
|
sa.Column("department_id", sa.Integer(), sa.ForeignKey("departments.id"), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"deviation_category_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("deviation_categories.id"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"disposition_authority_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("deviation_detail", sa.Text(), nullable=False),
|
||||||
|
sa.Column("requester_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("stage", sa.String(30), nullable=False),
|
||||||
|
sa.Column("stage_entered_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("qc_authority", sa.String(255), nullable=True),
|
||||||
|
sa.Column("work_order", sa.String(100), nullable=True),
|
||||||
|
sa.Column("disposition_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("secondary_review_needed", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"operations_complete", sa.Boolean(), nullable=False, server_default=sa.text("0")
|
||||||
|
),
|
||||||
|
sa.Column("operations_completed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"operations_completed_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True
|
||||||
|
),
|
||||||
|
sa.Column("qc_approval", sa.String(10), nullable=True),
|
||||||
|
sa.Column("inspection_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("qc_closed", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("qc_closed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("qc_closed_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("labor_cost", sa.Numeric(12, 2), nullable=True),
|
||||||
|
sa.Column("material_cost", sa.Numeric(12, 2), nullable=True),
|
||||||
|
sa.Column("service_cost", sa.Numeric(12, 2), nullable=True),
|
||||||
|
sa.Column("other_cost", sa.Numeric(12, 2), nullable=True),
|
||||||
|
sa.Column("costing_completed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"costing_completed_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True
|
||||||
|
),
|
||||||
|
sa.Column("closed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("closed_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.UniqueConstraint("ncr_number", name="uq_ncrs_ncr_number"),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
op.create_index("ix_ncrs_stage", "ncrs", ["stage"])
|
||||||
|
op.create_index("ix_ncrs_job_number", "ncrs", ["job_number"])
|
||||||
|
op.create_index("ix_ncrs_created_at", "ncrs", ["created_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ncr_secondary_assignees",
|
||||||
|
sa.Column(
|
||||||
|
"ncr_id", sa.Integer(), sa.ForeignKey("ncrs.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), primary_key=True),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"stage_transitions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"ncr_id", sa.Integer(), sa.ForeignKey("ncrs.id", ondelete="CASCADE"), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("from_stage", sa.String(30), nullable=True),
|
||||||
|
sa.Column("to_stage", sa.String(30), nullable=False),
|
||||||
|
sa.Column("action", sa.String(40), nullable=False),
|
||||||
|
sa.Column("acted_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("acted_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
op.create_index("ix_stage_transitions_ncr", "stage_transitions", ["ncr_id", "acted_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"job_info",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"ncr_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("ncrs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
sa.Column("part_id", sa.String(30), nullable=True),
|
||||||
|
sa.Column("part_description", sa.String(255), nullable=True),
|
||||||
|
sa.Column("customer_name", sa.String(100), nullable=True),
|
||||||
|
sa.Column("work_order_status", sa.String(20), nullable=True),
|
||||||
|
sa.Column("source", sa.String(20), nullable=False),
|
||||||
|
sa.Column("fetched_at", sa.DateTime(), nullable=False),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"attachments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"ncr_id", sa.Integer(), sa.ForeignKey("ncrs.id", ondelete="CASCADE"), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("original_filename", sa.String(255), nullable=False),
|
||||||
|
sa.Column("stored_path", sa.String(300), nullable=False, unique=True),
|
||||||
|
sa.Column("content_type", sa.String(100), nullable=False),
|
||||||
|
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("is_image", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("uploaded_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("uploaded_at", sa.DateTime(), nullable=False),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"audit_log",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"ncr_id", sa.Integer(), sa.ForeignKey("ncrs.id", ondelete="SET NULL"), nullable=True
|
||||||
|
),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("action", sa.String(40), nullable=False),
|
||||||
|
sa.Column("field_name", sa.String(100), nullable=True),
|
||||||
|
sa.Column("old_value", sa.Text(), nullable=True),
|
||||||
|
sa.Column("new_value", sa.Text(), nullable=True),
|
||||||
|
sa.Column("detail", sa.String(500), nullable=True),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
op.create_index("ix_audit_log_ncr", "audit_log", ["ncr_id", "created_at"])
|
||||||
|
op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"app_settings",
|
||||||
|
sa.Column("key", sa.String(100), primary_key=True),
|
||||||
|
sa.Column("value", sa.String(500), nullable=False),
|
||||||
|
**MYSQL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in (
|
||||||
|
"app_settings",
|
||||||
|
"audit_log",
|
||||||
|
"attachments",
|
||||||
|
"job_info",
|
||||||
|
"stage_transitions",
|
||||||
|
"ncr_secondary_assignees",
|
||||||
|
"ncrs",
|
||||||
|
"ncr_sequences",
|
||||||
|
"deviation_categories",
|
||||||
|
"departments",
|
||||||
|
"user_roles",
|
||||||
|
"users",
|
||||||
|
):
|
||||||
|
op.drop_table(table)
|
||||||
129
backend/alembic/versions/0002_reporting_views.py
Normal file
129
backend/alembic/versions/0002_reporting_views.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""read-only flattened reporting views for Power BI
|
||||||
|
|
||||||
|
Revision ID: 0002
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2026-07-13
|
||||||
|
|
||||||
|
The powerbi_ro MySQL user (created by db/init/01-powerbi-user.sh) has SELECT
|
||||||
|
on exactly these three views and nothing else.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0002"
|
||||||
|
down_revision = "0001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
VW_NCR_FULL = """
|
||||||
|
CREATE OR REPLACE VIEW vw_ncr_full AS
|
||||||
|
SELECT
|
||||||
|
n.id AS ncr_id,
|
||||||
|
n.ncr_number,
|
||||||
|
n.ncr_year,
|
||||||
|
n.ncr_seq,
|
||||||
|
n.created_at,
|
||||||
|
n.job_number,
|
||||||
|
d.name AS department,
|
||||||
|
dc.name AS deviation_category,
|
||||||
|
req.display_name AS requester,
|
||||||
|
req.email AS requester_email,
|
||||||
|
da.display_name AS disposition_authority,
|
||||||
|
n.stage,
|
||||||
|
n.stage_entered_at,
|
||||||
|
DATEDIFF(UTC_TIMESTAMP(), n.stage_entered_at) AS days_in_stage,
|
||||||
|
n.deviation_detail,
|
||||||
|
n.qc_authority,
|
||||||
|
n.work_order,
|
||||||
|
n.disposition_notes,
|
||||||
|
n.secondary_review_needed,
|
||||||
|
(SELECT GROUP_CONCAT(u2.display_name ORDER BY u2.display_name SEPARATOR '; ')
|
||||||
|
FROM ncr_secondary_assignees sa2
|
||||||
|
JOIN users u2 ON u2.id = sa2.user_id
|
||||||
|
WHERE sa2.ncr_id = n.id) AS secondary_authorities,
|
||||||
|
n.operations_complete,
|
||||||
|
n.operations_completed_at,
|
||||||
|
opu.display_name AS operations_completed_by,
|
||||||
|
n.qc_approval,
|
||||||
|
n.inspection_notes,
|
||||||
|
n.qc_closed,
|
||||||
|
n.qc_closed_at,
|
||||||
|
qcu.display_name AS qc_closed_by,
|
||||||
|
n.labor_cost,
|
||||||
|
n.material_cost,
|
||||||
|
n.service_cost,
|
||||||
|
n.other_cost,
|
||||||
|
COALESCE(n.labor_cost, 0) + COALESCE(n.material_cost, 0)
|
||||||
|
+ COALESCE(n.service_cost, 0) + COALESCE(n.other_cost, 0) AS total_cost,
|
||||||
|
n.costing_completed_at,
|
||||||
|
n.closed_at,
|
||||||
|
clu.display_name AS closed_by,
|
||||||
|
ji.part_id,
|
||||||
|
ji.part_description,
|
||||||
|
ji.customer_name,
|
||||||
|
ji.work_order_status,
|
||||||
|
(SELECT COUNT(*) FROM attachments a WHERE a.ncr_id = n.id) AS attachment_count
|
||||||
|
FROM ncrs n
|
||||||
|
JOIN departments d ON d.id = n.department_id
|
||||||
|
JOIN deviation_categories dc ON dc.id = n.deviation_category_id
|
||||||
|
JOIN users req ON req.id = n.requester_id
|
||||||
|
JOIN users da ON da.id = n.disposition_authority_id
|
||||||
|
LEFT JOIN users opu ON opu.id = n.operations_completed_by_id
|
||||||
|
LEFT JOIN users qcu ON qcu.id = n.qc_closed_by_id
|
||||||
|
LEFT JOIN users clu ON clu.id = n.closed_by_id
|
||||||
|
LEFT JOIN job_info ji ON ji.ncr_id = n.id
|
||||||
|
"""
|
||||||
|
|
||||||
|
VW_NCR_STAGE_HISTORY = """
|
||||||
|
CREATE OR REPLACE VIEW vw_ncr_stage_history AS
|
||||||
|
SELECT
|
||||||
|
t.id AS transition_id,
|
||||||
|
t.ncr_id,
|
||||||
|
n.ncr_number,
|
||||||
|
n.job_number,
|
||||||
|
t.from_stage,
|
||||||
|
t.to_stage,
|
||||||
|
t.action,
|
||||||
|
t.acted_at,
|
||||||
|
u.display_name AS acted_by,
|
||||||
|
u.email AS acted_by_email,
|
||||||
|
t.note
|
||||||
|
FROM stage_transitions t
|
||||||
|
JOIN ncrs n ON n.id = t.ncr_id
|
||||||
|
JOIN users u ON u.id = t.acted_by_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
VW_NCR_COSTS = """
|
||||||
|
CREATE OR REPLACE VIEW vw_ncr_costs AS
|
||||||
|
SELECT
|
||||||
|
n.id AS ncr_id,
|
||||||
|
n.ncr_number,
|
||||||
|
n.ncr_year,
|
||||||
|
n.job_number,
|
||||||
|
d.name AS department,
|
||||||
|
dc.name AS deviation_category,
|
||||||
|
n.created_at,
|
||||||
|
n.closed_at,
|
||||||
|
n.stage,
|
||||||
|
n.labor_cost,
|
||||||
|
n.material_cost,
|
||||||
|
n.service_cost,
|
||||||
|
n.other_cost,
|
||||||
|
COALESCE(n.labor_cost, 0) + COALESCE(n.material_cost, 0)
|
||||||
|
+ COALESCE(n.service_cost, 0) + COALESCE(n.other_cost, 0) AS total_cost
|
||||||
|
FROM ncrs n
|
||||||
|
JOIN departments d ON d.id = n.department_id
|
||||||
|
JOIN deviation_categories dc ON dc.id = n.deviation_category_id
|
||||||
|
WHERE n.costing_completed_at IS NOT NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(VW_NCR_FULL)
|
||||||
|
op.execute(VW_NCR_STAGE_HISTORY)
|
||||||
|
op.execute(VW_NCR_COSTS)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP VIEW IF EXISTS vw_ncr_costs")
|
||||||
|
op.execute("DROP VIEW IF EXISTS vw_ncr_stage_history")
|
||||||
|
op.execute("DROP VIEW IF EXISTS vw_ncr_full")
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
173
backend/app/auth/deps.py
Normal file
173
backend/app/auth/deps.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""Request authentication + authorization dependencies.
|
||||||
|
|
||||||
|
AUTH_MODE=entra: validates the bearer token, enforces the front-door group,
|
||||||
|
and auto-provisions a local user (OID, name, email) on first login.
|
||||||
|
|
||||||
|
AUTH_MODE=dev: trusts an X-Dev-User email header against seeded users.
|
||||||
|
Local development only.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.entra import AuthError, ensure_group_membership, validate_access_token
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.domain import Role
|
||||||
|
from app.models import User, UserRole
|
||||||
|
from app.models.base import utcnow
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
DEV_DEFAULT_USER = "admin@pescoinc.biz"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CurrentUser:
|
||||||
|
user: User
|
||||||
|
roles: set[str] = field(default_factory=set)
|
||||||
|
token: str | None = None # raw API access token (used for Graph OBO)
|
||||||
|
claims: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> int:
|
||||||
|
return self.user.id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
return Role.ADMIN.value in self.roles
|
||||||
|
|
||||||
|
def has_role(self, *roles: Role) -> bool:
|
||||||
|
return self.is_admin or any(r.value in self.roles for r in roles)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||||
|
result = await db.execute(select(User).where(User.email == email.lower()))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _provision_entra_user(db: AsyncSession, claims: dict) -> User:
|
||||||
|
settings = get_settings()
|
||||||
|
oid = claims.get("oid") or claims.get("sub")
|
||||||
|
email = (
|
||||||
|
claims.get("preferred_username")
|
||||||
|
or claims.get("email")
|
||||||
|
or claims.get("upn")
|
||||||
|
or ""
|
||||||
|
).lower()
|
||||||
|
name = claims.get("name") or email or "Unknown User"
|
||||||
|
employee_id = claims.get("employeeid") or claims.get("employee_id")
|
||||||
|
|
||||||
|
user = (
|
||||||
|
await db.execute(select(User).where(User.entra_oid == oid))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user is None and email:
|
||||||
|
user = await _load_user_by_email(db, email)
|
||||||
|
if user is not None and user.entra_oid is None:
|
||||||
|
user.entra_oid = oid # link pre-seeded user to their Entra identity
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
if not email:
|
||||||
|
raise AuthError("Token has no usable email/UPN claim.", 403)
|
||||||
|
user = User(
|
||||||
|
entra_oid=oid,
|
||||||
|
email=email,
|
||||||
|
display_name=name,
|
||||||
|
employee_id=employee_id,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
db.add(UserRole(user_id=user.id, role=Role.REQUESTER.value))
|
||||||
|
if email in settings.initial_admin_email_set:
|
||||||
|
db.add(UserRole(user_id=user.id, role=Role.ADMIN.value))
|
||||||
|
user.last_login_at = utcnow()
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Auto-provisioned user %s", email)
|
||||||
|
except IntegrityError:
|
||||||
|
# Concurrent first login for the same user — use the winner's row.
|
||||||
|
await db.rollback()
|
||||||
|
user = (
|
||||||
|
await db.execute(select(User).where(User.entra_oid == oid))
|
||||||
|
).scalar_one()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Keep profile fresh; throttle last_login writes to one per 15 minutes.
|
||||||
|
dirty = False
|
||||||
|
if name and user.display_name != name:
|
||||||
|
user.display_name = name
|
||||||
|
dirty = True
|
||||||
|
if email and user.email != email:
|
||||||
|
user.email = email
|
||||||
|
dirty = True
|
||||||
|
if employee_id and user.employee_id != employee_id:
|
||||||
|
user.employee_id = employee_id
|
||||||
|
dirty = True
|
||||||
|
if user.last_login_at is None or utcnow() - user.last_login_at > timedelta(minutes=15):
|
||||||
|
user.last_login_at = utcnow()
|
||||||
|
dirty = True
|
||||||
|
if dirty:
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> CurrentUser:
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if settings.auth_mode == "dev":
|
||||||
|
email = request.headers.get("X-Dev-User", DEV_DEFAULT_USER)
|
||||||
|
user = await _load_user_by_email(db, email)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail=f"Unknown dev user '{email}'. Run `python -m app.seed` "
|
||||||
|
"or pass a seeded email in the X-Dev-User header.",
|
||||||
|
)
|
||||||
|
return CurrentUser(user=user, roles=set(user.roles), token=None, claims={})
|
||||||
|
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing bearer token.")
|
||||||
|
token = credentials.credentials
|
||||||
|
try:
|
||||||
|
claims = await validate_access_token(token)
|
||||||
|
await ensure_group_membership(claims, token)
|
||||||
|
except AuthError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||||
|
|
||||||
|
user = await _provision_entra_user(db, claims)
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=403, detail="This account has been deactivated.")
|
||||||
|
return CurrentUser(user=user, roles=set(user.roles), token=token, claims=claims)
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(*roles: Role):
|
||||||
|
"""Dependency factory: caller must hold one of `roles` (Admin always passes)."""
|
||||||
|
|
||||||
|
async def dependency(
|
||||||
|
current: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if current.has_role(*roles):
|
||||||
|
return current
|
||||||
|
needed = ", ".join(r.value for r in roles)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail=f"This action requires one of the roles: {needed}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
require_admin = require_roles(Role.ADMIN)
|
||||||
124
backend/app/auth/entra.py
Normal file
124
backend/app/auth/entra.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""Entra ID access-token validation (python-jose + tenant JWKS)."""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(Exception):
|
||||||
|
def __init__(self, message: str, status_code: int = 401):
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
_jwks: dict[str, dict] = {}
|
||||||
|
_jwks_fetched_at: float = 0.0
|
||||||
|
_JWKS_TTL = 60 * 60 * 12
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_jwks() -> None:
|
||||||
|
global _jwks, _jwks_fetched_at
|
||||||
|
settings = get_settings()
|
||||||
|
url = (
|
||||||
|
f"https://login.microsoftonline.com/{settings.entra_tenant_id}"
|
||||||
|
"/discovery/v2.0/keys"
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
_jwks = {k["kid"]: k for k in resp.json().get("keys", [])}
|
||||||
|
_jwks_fetched_at = time.time()
|
||||||
|
logger.info("Fetched %d Entra signing keys", len(_jwks))
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_signing_key(kid: str) -> dict:
|
||||||
|
stale = time.time() - _jwks_fetched_at > _JWKS_TTL
|
||||||
|
if kid not in _jwks or stale:
|
||||||
|
await _fetch_jwks()
|
||||||
|
key = _jwks.get(kid)
|
||||||
|
if key is None:
|
||||||
|
raise AuthError("Token signed with an unknown key.")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_access_token(token: str) -> dict:
|
||||||
|
"""Validate signature, expiry, audience, and issuer; return claims."""
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.entra_tenant_id or not settings.entra_client_id:
|
||||||
|
raise AuthError(
|
||||||
|
"Entra ID is not configured (ENTRA_TENANT_ID / ENTRA_CLIENT_ID).", 503
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
header = jwt.get_unverified_header(token)
|
||||||
|
key = await _get_signing_key(header.get("kid", ""))
|
||||||
|
claims = jwt.decode(
|
||||||
|
token,
|
||||||
|
key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
options={"verify_aud": False}, # audience list checked below
|
||||||
|
)
|
||||||
|
except AuthError:
|
||||||
|
raise
|
||||||
|
except JWTError as exc:
|
||||||
|
raise AuthError(f"Invalid token: {exc}") from exc
|
||||||
|
|
||||||
|
aud = claims.get("aud")
|
||||||
|
if aud not in settings.api_audiences:
|
||||||
|
raise AuthError("Token audience does not match this API.")
|
||||||
|
|
||||||
|
tid = settings.entra_tenant_id
|
||||||
|
valid_issuers = {
|
||||||
|
f"https://login.microsoftonline.com/{tid}/v2.0",
|
||||||
|
f"https://sts.windows.net/{tid}/",
|
||||||
|
}
|
||||||
|
if claims.get("iss") not in valid_issuers:
|
||||||
|
raise AuthError("Token issuer does not match the configured tenant.")
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_group_membership(claims: dict, token: str) -> None:
|
||||||
|
"""Front-door gate: require membership in ENTRA_ALLOWED_GROUP_ID.
|
||||||
|
|
||||||
|
Uses the `groups` claim when present; on claim overage falls back to a
|
||||||
|
delegated Graph checkMemberGroups call.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
group_id = settings.entra_allowed_group_id
|
||||||
|
if not group_id:
|
||||||
|
return # gate disabled by configuration
|
||||||
|
|
||||||
|
groups = claims.get("groups")
|
||||||
|
if groups is not None:
|
||||||
|
if group_id in groups:
|
||||||
|
return
|
||||||
|
raise AuthError(
|
||||||
|
"Your account is not a member of the NCR access group.", 403
|
||||||
|
)
|
||||||
|
|
||||||
|
claim_names = claims.get("_claim_names") or {}
|
||||||
|
if "groups" in claim_names:
|
||||||
|
# Group overage: too many groups to embed in the token.
|
||||||
|
from app.services.graph import check_member_group
|
||||||
|
|
||||||
|
try:
|
||||||
|
if await check_member_group(token, group_id):
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Group overage Graph check failed: %s", exc)
|
||||||
|
raise AuthError(
|
||||||
|
"Could not verify group membership (group overage). Ensure the "
|
||||||
|
"app has delegated GroupMember.Read.All consent, or scope the "
|
||||||
|
"group claim to 'Groups assigned to the application'.", 403
|
||||||
|
) from exc
|
||||||
|
raise AuthError("Your account is not a member of the NCR access group.", 403)
|
||||||
|
|
||||||
|
raise AuthError(
|
||||||
|
"Access token has no groups claim. Add the groups claim in the app "
|
||||||
|
"registration (Token configuration → Add groups claim).", 403
|
||||||
|
)
|
||||||
88
backend/app/config.py
Normal file
88
backend/app/config.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Application configuration, sourced from environment variables (see .env.example)."""
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
app_name: str = "PESCO NCR"
|
||||||
|
app_base_url: str = "http://localhost:8080"
|
||||||
|
log_level: str = "INFO"
|
||||||
|
|
||||||
|
# ── Auth ────────────────────────────────────────────────────────────────
|
||||||
|
# "entra" validates Entra ID JWTs; "dev" trusts an X-Dev-User header and
|
||||||
|
# must never be used outside local development.
|
||||||
|
auth_mode: Literal["entra", "dev"] = "entra"
|
||||||
|
entra_tenant_id: str = ""
|
||||||
|
entra_client_id: str = ""
|
||||||
|
entra_client_secret: str = ""
|
||||||
|
entra_allowed_group_id: str = ""
|
||||||
|
entra_api_audience: str = ""
|
||||||
|
initial_admin_emails: str = ""
|
||||||
|
|
||||||
|
# ── Database ────────────────────────────────────────────────────────────
|
||||||
|
# Full SQLAlchemy URL override (used by tests); otherwise assembled from
|
||||||
|
# the MYSQL_* parts below.
|
||||||
|
database_url: str = ""
|
||||||
|
mysql_host: str = "mysql"
|
||||||
|
mysql_port: int = 3306
|
||||||
|
mysql_database: str = "pesco_ncr"
|
||||||
|
mysql_user: str = "ncr_app"
|
||||||
|
mysql_password: str = ""
|
||||||
|
|
||||||
|
# ── Attachments ─────────────────────────────────────────────────────────
|
||||||
|
attachments_dir: str = "/data/attachments"
|
||||||
|
max_upload_mb: int = 25
|
||||||
|
|
||||||
|
# ── Notifications ───────────────────────────────────────────────────────
|
||||||
|
notifications_enabled_default: bool = True
|
||||||
|
|
||||||
|
# ── Job lookup (future VISUAL integration) ──────────────────────────────
|
||||||
|
job_lookup_provider: Literal["null", "visual"] = "null"
|
||||||
|
visual_db_host: str = ""
|
||||||
|
visual_db_port: int = 1433
|
||||||
|
visual_db_name: str = ""
|
||||||
|
visual_db_user: str = ""
|
||||||
|
visual_db_password: str = ""
|
||||||
|
visual_site_id: str = ""
|
||||||
|
|
||||||
|
seed_demo_data: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_database_url(self) -> str:
|
||||||
|
if self.database_url:
|
||||||
|
return self.database_url
|
||||||
|
return (
|
||||||
|
f"mysql+aiomysql://{quote_plus(self.mysql_user)}:{quote_plus(self.mysql_password)}"
|
||||||
|
f"@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}?charset=utf8mb4"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sync_database_url(self) -> str:
|
||||||
|
"""Synchronous-driver URL for Alembic."""
|
||||||
|
return self.effective_database_url.replace("+aiomysql", "+pymysql").replace(
|
||||||
|
"+aiosqlite", ""
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_audiences(self) -> list[str]:
|
||||||
|
if self.entra_api_audience:
|
||||||
|
return [self.entra_api_audience]
|
||||||
|
return [f"api://{self.entra_client_id}", self.entra_client_id]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initial_admin_email_set(self) -> set[str]:
|
||||||
|
return {e.strip().lower() for e in self.initial_admin_emails.split(",") if e.strip()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_upload_bytes(self) -> int:
|
||||||
|
return self.max_upload_mb * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
68
backend/app/database.py
Normal file
68
backend/app/database.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Async SQLAlchemy engine/session setup.
|
||||||
|
|
||||||
|
The engine is created lazily so importing the app (e.g. in tests that override
|
||||||
|
`get_db`) never requires a reachable MySQL server or its driver.
|
||||||
|
"""
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
_engine: AsyncEngine | None = None
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine() -> AsyncEngine:
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
settings = get_settings()
|
||||||
|
url = settings.effective_database_url
|
||||||
|
if url.startswith("sqlite"):
|
||||||
|
# Test runs: fresh connection per checkout (no cross-event-loop
|
||||||
|
# reuse) and a generous busy timeout for concurrent writers.
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
_engine = create_async_engine(
|
||||||
|
url, poolclass=NullPool, connect_args={"timeout": 30}
|
||||||
|
)
|
||||||
|
|
||||||
|
# SQLite (rollback-journal) deadlocks when a transaction upgrades
|
||||||
|
# from read to write while another writer waits. Taking the write
|
||||||
|
# lock up front (BEGIN IMMEDIATE) serializes transactions cleanly,
|
||||||
|
# mirroring the row-lock semantics InnoDB gives us in production.
|
||||||
|
@event.listens_for(_engine.sync_engine, "connect")
|
||||||
|
def _sqlite_autocommit(dbapi_conn, _record):
|
||||||
|
dbapi_conn.isolation_level = None
|
||||||
|
|
||||||
|
@event.listens_for(_engine.sync_engine, "begin")
|
||||||
|
def _sqlite_begin_immediate(conn):
|
||||||
|
conn.exec_driver_sql("BEGIN IMMEDIATE")
|
||||||
|
else:
|
||||||
|
_engine = create_async_engine(
|
||||||
|
url,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=1800,
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||||
|
global _session_factory
|
||||||
|
if _session_factory is None:
|
||||||
|
_session_factory = async_sessionmaker(
|
||||||
|
get_engine(), expire_on_commit=False, autoflush=False
|
||||||
|
)
|
||||||
|
return _session_factory
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
yield session
|
||||||
21
backend/app/dev_init.py
Normal file
21
backend/app/dev_init.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Create the schema directly from the models — for LOCAL SQLite development
|
||||||
|
only (`python -m app.dev_init`). Real MySQL deployments use Alembic
|
||||||
|
(`alembic upgrade head`), which also creates the Power BI reporting views.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_engine
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
url = get_settings().effective_database_url
|
||||||
|
engine = get_engine()
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
print(f"Schema created for {url}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
84
backend/app/domain.py
Normal file
84
backend/app/domain.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Domain constants: roles, workflow stages, and the transition map.
|
||||||
|
|
||||||
|
Workflow note: an NCR in the "New Request" stage is awaiting Initial
|
||||||
|
Disposition — the initial-disposition review is the action a Disposition
|
||||||
|
Authority performs on a New Request, and it moves the NCR either to
|
||||||
|
Secondary Disposition (when secondary review is required) or straight to
|
||||||
|
Operations. All transitions are validated server-side against
|
||||||
|
ALLOWED_TRANSITIONS; anything else is rejected with HTTP 409.
|
||||||
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Role(str, Enum):
|
||||||
|
REQUESTER = "requester"
|
||||||
|
DISPOSITION_AUTHORITY = "disposition_authority"
|
||||||
|
SECONDARY_DISPOSITION_AUTHORITY = "secondary_disposition_authority"
|
||||||
|
OPERATIONS = "operations"
|
||||||
|
QC_INSPECTOR = "qc_inspector"
|
||||||
|
COSTING = "costing"
|
||||||
|
ADMIN = "admin"
|
||||||
|
|
||||||
|
|
||||||
|
ALL_ROLES: set[str] = {r.value for r in Role}
|
||||||
|
|
||||||
|
ROLE_LABELS: dict[str, str] = {
|
||||||
|
Role.REQUESTER: "Requester",
|
||||||
|
Role.DISPOSITION_AUTHORITY: "Disposition Authority",
|
||||||
|
Role.SECONDARY_DISPOSITION_AUTHORITY: "Secondary Disposition Authority",
|
||||||
|
Role.OPERATIONS: "Operations",
|
||||||
|
Role.QC_INSPECTOR: "QC Inspector",
|
||||||
|
Role.COSTING: "Costing",
|
||||||
|
Role.ADMIN: "Admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Stage(str, Enum):
|
||||||
|
NEW_REQUEST = "new_request"
|
||||||
|
SECONDARY_DISPOSITION = "secondary_disposition"
|
||||||
|
OPERATIONS = "operations"
|
||||||
|
QC_INSPECTION = "qc_inspection"
|
||||||
|
COSTING = "costing"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
STAGE_LABELS: dict[str, str] = {
|
||||||
|
Stage.NEW_REQUEST: "New Request",
|
||||||
|
Stage.SECONDARY_DISPOSITION: "Secondary Disposition",
|
||||||
|
Stage.OPERATIONS: "Operations",
|
||||||
|
Stage.QC_INSPECTION: "QC Inspection",
|
||||||
|
Stage.COSTING: "Costing",
|
||||||
|
Stage.CLOSED: "Closed",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stages an admin may reopen a closed NCR back into.
|
||||||
|
REOPEN_TARGET_STAGES: list[Stage] = [
|
||||||
|
Stage.NEW_REQUEST,
|
||||||
|
Stage.SECONDARY_DISPOSITION,
|
||||||
|
Stage.OPERATIONS,
|
||||||
|
Stage.QC_INSPECTION,
|
||||||
|
Stage.COSTING,
|
||||||
|
]
|
||||||
|
|
||||||
|
ALLOWED_TRANSITIONS: dict[Stage, set[Stage]] = {
|
||||||
|
Stage.NEW_REQUEST: {Stage.SECONDARY_DISPOSITION, Stage.OPERATIONS},
|
||||||
|
Stage.SECONDARY_DISPOSITION: {Stage.OPERATIONS},
|
||||||
|
Stage.OPERATIONS: {Stage.QC_INSPECTION},
|
||||||
|
Stage.QC_INSPECTION: {Stage.COSTING},
|
||||||
|
Stage.COSTING: {Stage.CLOSED},
|
||||||
|
# Reopen (admin only, reason required) — enforced separately.
|
||||||
|
Stage.CLOSED: set(REOPEN_TARGET_STAGES),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Role allowed to act on the NCR in each stage (Admin is always allowed;
|
||||||
|
# Secondary Disposition additionally requires being an assigned authority).
|
||||||
|
STAGE_ACTING_ROLE: dict[Stage, Role] = {
|
||||||
|
Stage.NEW_REQUEST: Role.DISPOSITION_AUTHORITY,
|
||||||
|
Stage.SECONDARY_DISPOSITION: Role.SECONDARY_DISPOSITION_AUTHORITY,
|
||||||
|
Stage.OPERATIONS: Role.OPERATIONS,
|
||||||
|
Stage.QC_INSPECTION: Role.QC_INSPECTOR,
|
||||||
|
Stage.COSTING: Role.COSTING,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Owners to notify when an admin reopens an NCR into a given stage.
|
||||||
|
STAGE_OWNER_ROLE: dict[Stage, Role] = STAGE_ACTING_ROLE
|
||||||
57
backend/app/main.py
Normal file
57
backend/app/main.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.routers import admin, health, jobs, lookups, ncrs, reports, users
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
settings = get_settings()
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
Path(settings.attachments_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
if settings.auth_mode == "dev":
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"AUTH_MODE=dev — authentication is BYPASSED. Never use in production."
|
||||||
|
)
|
||||||
|
# Fail fast on a misconfigured job-lookup provider.
|
||||||
|
from app.services.job_lookup import get_job_lookup_service
|
||||||
|
|
||||||
|
get_job_lookup_service()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="PESCO NCR API",
|
||||||
|
version="1.0.0",
|
||||||
|
docs_url="/api/docs",
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
redoc_url=None,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# In production nginx serves the SPA and proxies /api same-origin, so CORS is
|
||||||
|
# only exercised by the Vite dev server.
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
API = "/api"
|
||||||
|
app.include_router(health.router, prefix=API)
|
||||||
|
app.include_router(users.router, prefix=API)
|
||||||
|
app.include_router(lookups.router, prefix=API)
|
||||||
|
app.include_router(jobs.router, prefix=API)
|
||||||
|
app.include_router(ncrs.router, prefix=API)
|
||||||
|
app.include_router(reports.router, prefix=API)
|
||||||
|
app.include_router(admin.router, prefix=API)
|
||||||
29
backend/app/models/__init__.py
Normal file
29
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from app.models.base import Base
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.lookups import Department, DeviationCategory
|
||||||
|
from app.models.ncr import (
|
||||||
|
JobInfo,
|
||||||
|
Ncr,
|
||||||
|
NcrSecondaryAssignee,
|
||||||
|
NcrSequence,
|
||||||
|
StageTransition,
|
||||||
|
)
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.app_setting import AppSetting
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"User",
|
||||||
|
"UserRole",
|
||||||
|
"Department",
|
||||||
|
"DeviationCategory",
|
||||||
|
"Ncr",
|
||||||
|
"NcrSequence",
|
||||||
|
"NcrSecondaryAssignee",
|
||||||
|
"StageTransition",
|
||||||
|
"JobInfo",
|
||||||
|
"Attachment",
|
||||||
|
"AuditLog",
|
||||||
|
"AppSetting",
|
||||||
|
]
|
||||||
14
backend/app/models/app_setting.py
Normal file
14
backend/app/models/app_setting.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from sqlalchemy import String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AppSetting(Base):
|
||||||
|
__tablename__ = "app_settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column(String(500))
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFICATIONS_ENABLED_KEY = "notifications_enabled"
|
||||||
25
backend/app/models/attachment.py
Normal file
25
backend/app/models/attachment.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, utcnow
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class Attachment(Base):
|
||||||
|
__tablename__ = "attachments"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
ncr_id: Mapped[int] = mapped_column(ForeignKey("ncrs.id", ondelete="CASCADE"))
|
||||||
|
original_filename: Mapped[str] = mapped_column(String(255))
|
||||||
|
# Relative path under ATTACHMENTS_DIR: "<ncr_id>/<uuid><ext>"
|
||||||
|
stored_path: Mapped[str] = mapped_column(String(300), unique=True)
|
||||||
|
content_type: Mapped[str] = mapped_column(String(100))
|
||||||
|
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||||
|
is_image: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
uploaded_by_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||||
|
uploaded_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||||
|
|
||||||
|
ncr = relationship("Ncr", back_populates="attachments")
|
||||||
|
uploaded_by: Mapped[User] = relationship(lazy="selectin")
|
||||||
37
backend/app/models/audit.py
Normal file
37
backend/app/models/audit.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, utcnow
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
"""Immutable audit record. The application exposes no update or delete
|
||||||
|
path for these rows; one row per changed field (field_name null for
|
||||||
|
record-level events such as create/transition/attachment/reopen)."""
|
||||||
|
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_audit_log_ncr", "ncr_id", "created_at"),
|
||||||
|
Index("ix_audit_log_created_at", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# BigInteger on MySQL; plain INTEGER on SQLite (required for autoincrement).
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True
|
||||||
|
)
|
||||||
|
# Nullable so admin actions without an NCR (settings, role changes) are auditable too.
|
||||||
|
ncr_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("ncrs.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||||
|
action: Mapped[str] = mapped_column(String(40))
|
||||||
|
field_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
old_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
new_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
detail: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(lazy="selectin")
|
||||||
12
backend/app/models/base.py
Normal file
12
backend/app/models/base.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
"""Naive UTC timestamp — all datetimes are stored as UTC in MySQL DATETIME."""
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
22
backend/app/models/lookups.py
Normal file
22
backend/app/models/lookups.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from sqlalchemy import Boolean, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Department(Base):
|
||||||
|
__tablename__ = "departments"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
|
# Deactivated values are hidden from new-NCR forms but remain valid on
|
||||||
|
# existing records; values referenced by NCRs are never hard-deleted.
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviationCategory(Base):
|
||||||
|
__tablename__ = "deviation_categories"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
195
backend/app/models/ncr.py
Normal file
195
backend/app/models/ncr.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, utcnow
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class NcrSequence(Base):
|
||||||
|
"""Per-year NCR number allocator. Incremented atomically inside the
|
||||||
|
NCR-creation transaction (row lock held until commit) so concurrent
|
||||||
|
submissions can never produce the same number."""
|
||||||
|
|
||||||
|
__tablename__ = "ncr_sequences"
|
||||||
|
|
||||||
|
year: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
|
||||||
|
last_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Ncr(Base):
|
||||||
|
__tablename__ = "ncrs"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("ncr_number", name="uq_ncrs_ncr_number"),
|
||||||
|
Index("ix_ncrs_stage", "stage"),
|
||||||
|
Index("ix_ncrs_job_number", "job_number"),
|
||||||
|
Index("ix_ncrs_created_at", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
ncr_number: Mapped[str] = mapped_column(String(20))
|
||||||
|
ncr_year: Mapped[int] = mapped_column(Integer)
|
||||||
|
ncr_seq: Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
|
# ── Request (stage 1) ────────────────────────────────────────────────────
|
||||||
|
job_number: Mapped[str] = mapped_column(String(100))
|
||||||
|
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id"))
|
||||||
|
deviation_category_id: Mapped[int] = mapped_column(ForeignKey("deviation_categories.id"))
|
||||||
|
disposition_authority_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||||
|
deviation_detail: Mapped[str] = mapped_column(Text)
|
||||||
|
requester_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||||
|
|
||||||
|
# ── Workflow state ───────────────────────────────────────────────────────
|
||||||
|
stage: Mapped[str] = mapped_column(String(30))
|
||||||
|
stage_entered_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
# ── Disposition (initial + secondary) ────────────────────────────────────
|
||||||
|
qc_authority: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
work_order: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
disposition_notes: Mapped[str | None] = mapped_column(Text, nullable=True) # sanitized HTML
|
||||||
|
secondary_review_needed: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
|
||||||
|
# ── Operations ───────────────────────────────────────────────────────────
|
||||||
|
operations_complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
operations_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
operations_completed_by_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("users.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── QC Inspection ────────────────────────────────────────────────────────
|
||||||
|
qc_approval: Mapped[str | None] = mapped_column(String(10), nullable=True) # yes | no
|
||||||
|
inspection_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
qc_closed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
qc_closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
qc_closed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
# ── Costing ──────────────────────────────────────────────────────────────
|
||||||
|
labor_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||||
|
material_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||||
|
service_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||||
|
other_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||||
|
costing_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
costing_completed_by_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("users.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Closure ──────────────────────────────────────────────────────────────
|
||||||
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
closed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
# ── Relationships ────────────────────────────────────────────────────────
|
||||||
|
department = relationship("Department", lazy="selectin")
|
||||||
|
deviation_category = relationship("DeviationCategory", lazy="selectin")
|
||||||
|
requester: Mapped[User] = relationship(foreign_keys=[requester_id], lazy="selectin")
|
||||||
|
disposition_authority: Mapped[User] = relationship(
|
||||||
|
foreign_keys=[disposition_authority_id], lazy="selectin"
|
||||||
|
)
|
||||||
|
operations_completed_by: Mapped[User | None] = relationship(
|
||||||
|
foreign_keys=[operations_completed_by_id], lazy="selectin"
|
||||||
|
)
|
||||||
|
qc_closed_by: Mapped[User | None] = relationship(
|
||||||
|
foreign_keys=[qc_closed_by_id], lazy="selectin"
|
||||||
|
)
|
||||||
|
costing_completed_by: Mapped[User | None] = relationship(
|
||||||
|
foreign_keys=[costing_completed_by_id], lazy="selectin"
|
||||||
|
)
|
||||||
|
closed_by: Mapped[User | None] = relationship(foreign_keys=[closed_by_id], lazy="selectin")
|
||||||
|
|
||||||
|
secondary_assignee_rows: Mapped[list["NcrSecondaryAssignee"]] = relationship(
|
||||||
|
back_populates="ncr", cascade="all, delete-orphan", lazy="selectin"
|
||||||
|
)
|
||||||
|
transitions: Mapped[list["StageTransition"]] = relationship(
|
||||||
|
back_populates="ncr",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
|
order_by="StageTransition.acted_at",
|
||||||
|
)
|
||||||
|
attachments: Mapped[list["Attachment"]] = relationship( # noqa: F821
|
||||||
|
back_populates="ncr", cascade="all, delete-orphan", lazy="selectin"
|
||||||
|
)
|
||||||
|
job_info: Mapped["JobInfo | None"] = relationship(
|
||||||
|
back_populates="ncr", cascade="all, delete-orphan", lazy="selectin", uselist=False
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secondary_authorities(self) -> list[User]:
|
||||||
|
return [row.user for row in self.secondary_assignee_rows]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_cost(self) -> Decimal | None:
|
||||||
|
costs = [self.labor_cost, self.material_cost, self.service_cost, self.other_cost]
|
||||||
|
present = [c for c in costs if c is not None]
|
||||||
|
if not present:
|
||||||
|
return None
|
||||||
|
return sum(present, Decimal("0"))
|
||||||
|
|
||||||
|
|
||||||
|
class NcrSecondaryAssignee(Base):
|
||||||
|
"""Users selected as 'Notify These People' for secondary disposition."""
|
||||||
|
|
||||||
|
__tablename__ = "ncr_secondary_assignees"
|
||||||
|
|
||||||
|
ncr_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("ncrs.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
|
||||||
|
|
||||||
|
ncr: Mapped[Ncr] = relationship(back_populates="secondary_assignee_rows")
|
||||||
|
user: Mapped[User] = relationship(lazy="selectin")
|
||||||
|
|
||||||
|
|
||||||
|
class StageTransition(Base):
|
||||||
|
"""One row per lifecycle event (create, stage change, reopen) — the basis
|
||||||
|
for aging and cycle-time reporting."""
|
||||||
|
|
||||||
|
__tablename__ = "stage_transitions"
|
||||||
|
__table_args__ = (Index("ix_stage_transitions_ncr", "ncr_id", "acted_at"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
ncr_id: Mapped[int] = mapped_column(ForeignKey("ncrs.id", ondelete="CASCADE"))
|
||||||
|
from_stage: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
to_stage: Mapped[str] = mapped_column(String(30))
|
||||||
|
action: Mapped[str] = mapped_column(String(40))
|
||||||
|
acted_by_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||||
|
acted_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True) # e.g. reopen reason
|
||||||
|
|
||||||
|
ncr: Mapped[Ncr] = relationship(back_populates="transitions")
|
||||||
|
acted_by: Mapped[User] = relationship(lazy="selectin")
|
||||||
|
|
||||||
|
|
||||||
|
class JobInfo(Base):
|
||||||
|
"""Read-only enrichment for a job number, populated by a JobLookupService.
|
||||||
|
|
||||||
|
Stays empty under NullJobLookupService; the future VisualJobLookupService
|
||||||
|
will fill it from Infor VISUAL (WORK_ORDER + customer order linkage).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "job_info"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
ncr_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("ncrs.id", ondelete="CASCADE"), unique=True
|
||||||
|
)
|
||||||
|
part_id: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
part_description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
customer_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
work_order_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(20), default="null")
|
||||||
|
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||||
|
|
||||||
|
ncr: Mapped[Ncr] = relationship(back_populates="job_info")
|
||||||
39
backend/app/models/user.py
Normal file
39
backend/app/models/user.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
# Entra object id; null for dev-mode/seeded users.
|
||||||
|
entra_oid: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(255))
|
||||||
|
employee_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
role_rows: Mapped[list["UserRole"]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan", lazy="selectin"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def roles(self) -> list[str]:
|
||||||
|
return sorted(r.role for r in self.role_rows)
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(Base):
|
||||||
|
__tablename__ = "user_roles"
|
||||||
|
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="role_rows")
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
312
backend/app/routers/admin.py
Normal file
312
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
"""Admin area: role management, department/category lists, notification
|
||||||
|
toggle, and the global audit log. All endpoints are Admin-only."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.deps import CurrentUser, require_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app.domain import Role
|
||||||
|
from app.models import (
|
||||||
|
AppSetting,
|
||||||
|
AuditLog,
|
||||||
|
Department,
|
||||||
|
DeviationCategory,
|
||||||
|
Ncr,
|
||||||
|
User,
|
||||||
|
UserRole,
|
||||||
|
)
|
||||||
|
from app.models.app_setting import NOTIFICATIONS_ENABLED_KEY
|
||||||
|
from app.schemas.lookup import LookupCreateIn, LookupPatchIn, NamedLookupOut
|
||||||
|
from app.schemas.ncr import AuditEntryOut
|
||||||
|
from app.schemas.user import RolesUpdateIn, UserOut
|
||||||
|
from app.services.audit import audit_event
|
||||||
|
from app.services.notifications import notifications_enabled
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
def _user_out(u: User) -> UserOut:
|
||||||
|
return UserOut(
|
||||||
|
id=u.id,
|
||||||
|
display_name=u.display_name,
|
||||||
|
email=u.email,
|
||||||
|
employee_id=u.employee_id,
|
||||||
|
is_active=u.is_active,
|
||||||
|
roles=u.roles,
|
||||||
|
last_login_at=u.last_login_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── users & roles ────────────────────────────────────────────────────────────
|
||||||
|
@router.get("/users", response_model=list[UserOut])
|
||||||
|
async def list_all_users(
|
||||||
|
search: str | None = None,
|
||||||
|
_: CurrentUser = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[UserOut]:
|
||||||
|
stmt = select(User).order_by(User.display_name)
|
||||||
|
if search:
|
||||||
|
like = f"%{search.strip()}%"
|
||||||
|
stmt = stmt.where(User.display_name.like(like) | User.email.like(like))
|
||||||
|
users = (await db.execute(stmt)).scalars().unique().all()
|
||||||
|
return [_user_out(u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/roles", response_model=UserOut)
|
||||||
|
async def set_user_roles(
|
||||||
|
user_id: int,
|
||||||
|
payload: RolesUpdateIn,
|
||||||
|
current: CurrentUser = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> UserOut:
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found.")
|
||||||
|
if user.id == current.id and Role.ADMIN.value not in payload.roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="You cannot remove your own Admin role (lockout protection).",
|
||||||
|
)
|
||||||
|
old_roles = user.roles
|
||||||
|
user.role_rows = [UserRole(user_id=user.id, role=r) for r in payload.roles]
|
||||||
|
audit_event(
|
||||||
|
db,
|
||||||
|
user_id=current.id,
|
||||||
|
action="roles_update",
|
||||||
|
field_name=f"user:{user.email}",
|
||||||
|
old_value=", ".join(old_roles) or "(none)",
|
||||||
|
new_value=", ".join(payload.roles) or "(none)",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return _user_out(user)
|
||||||
|
|
||||||
|
|
||||||
|
class ActivePatchIn(BaseModel):
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/active", response_model=UserOut)
|
||||||
|
async def set_user_active(
|
||||||
|
user_id: int,
|
||||||
|
payload: ActivePatchIn,
|
||||||
|
current: CurrentUser = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> UserOut:
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found.")
|
||||||
|
if user.id == current.id and not payload.is_active:
|
||||||
|
raise HTTPException(status_code=422, detail="You cannot deactivate yourself.")
|
||||||
|
if user.is_active != payload.is_active:
|
||||||
|
audit_event(
|
||||||
|
db,
|
||||||
|
user_id=current.id,
|
||||||
|
action="user_active",
|
||||||
|
field_name=f"user:{user.email}",
|
||||||
|
old_value=user.is_active,
|
||||||
|
new_value=payload.is_active,
|
||||||
|
)
|
||||||
|
user.is_active = payload.is_active
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return _user_out(user)
|
||||||
|
|
||||||
|
|
||||||
|
# ── departments & deviation categories ──────────────────────────────────────
|
||||||
|
# No hard-delete endpoints exist by design: values referenced by existing
|
||||||
|
# NCRs are only ever deactivated.
|
||||||
|
@router.get("/departments", response_model=list[NamedLookupOut])
|
||||||
|
async def list_departments(
|
||||||
|
_: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
rows = (await db.execute(select(Department).order_by(Department.name))).scalars().all()
|
||||||
|
return [NamedLookupOut.model_validate(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/departments", response_model=NamedLookupOut, status_code=201)
|
||||||
|
async def create_department(
|
||||||
|
payload: LookupCreateIn,
|
||||||
|
current: CurrentUser = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await _create_lookup(Department, "Department", payload, current, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/departments/{item_id}", response_model=NamedLookupOut)
|
||||||
|
async def patch_department(
|
||||||
|
item_id: int,
|
||||||
|
payload: LookupPatchIn,
|
||||||
|
current: CurrentUser = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await _patch_lookup(Department, "Department", item_id, payload, current, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories", response_model=list[NamedLookupOut])
|
||||||
|
async def list_categories(
|
||||||
|
_: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
rows = (
|
||||||
|
(await db.execute(select(DeviationCategory).order_by(DeviationCategory.name)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [NamedLookupOut.model_validate(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories", response_model=NamedLookupOut, status_code=201)
|
||||||
|
async def create_category(
|
||||||
|
payload: LookupCreateIn,
|
||||||
|
current: CurrentUser = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await _create_lookup(DeviationCategory, "Deviation category", payload, current, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/categories/{item_id}", response_model=NamedLookupOut)
|
||||||
|
async def patch_category(
|
||||||
|
item_id: int,
|
||||||
|
payload: LookupPatchIn,
|
||||||
|
current: CurrentUser = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await _patch_lookup(
|
||||||
|
DeviationCategory, "Deviation category", item_id, payload, current, db
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_lookup(model, label, payload, current, db) -> NamedLookupOut:
|
||||||
|
exists = (
|
||||||
|
await db.execute(select(model).where(model.name == payload.name.strip()))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if exists:
|
||||||
|
raise HTTPException(status_code=409, detail=f"{label} already exists.")
|
||||||
|
row = model(name=payload.name.strip(), is_active=True)
|
||||||
|
db.add(row)
|
||||||
|
audit_event(
|
||||||
|
db, user_id=current.id, action="lookup_create", field_name=label, new_value=payload.name
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(row)
|
||||||
|
return NamedLookupOut.model_validate(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def _patch_lookup(model, label, item_id, payload, current, db) -> NamedLookupOut:
|
||||||
|
row = await db.get(model, item_id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"{label} not found.")
|
||||||
|
if payload.name is not None and payload.name.strip() != row.name:
|
||||||
|
audit_event(
|
||||||
|
db,
|
||||||
|
user_id=current.id,
|
||||||
|
action="lookup_rename",
|
||||||
|
field_name=label,
|
||||||
|
old_value=row.name,
|
||||||
|
new_value=payload.name.strip(),
|
||||||
|
)
|
||||||
|
row.name = payload.name.strip()
|
||||||
|
if payload.is_active is not None and payload.is_active != row.is_active:
|
||||||
|
audit_event(
|
||||||
|
db,
|
||||||
|
user_id=current.id,
|
||||||
|
action="lookup_active",
|
||||||
|
field_name=f"{label}: {row.name}",
|
||||||
|
old_value=row.is_active,
|
||||||
|
new_value=payload.is_active,
|
||||||
|
)
|
||||||
|
row.is_active = payload.is_active
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(row)
|
||||||
|
return NamedLookupOut.model_validate(row)
|
||||||
|
|
||||||
|
|
||||||
|
# ── settings ─────────────────────────────────────────────────────────────────
|
||||||
|
class SettingsOut(BaseModel):
|
||||||
|
notifications_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_model=SettingsOut)
|
||||||
|
async def get_admin_settings(
|
||||||
|
_: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> SettingsOut:
|
||||||
|
return SettingsOut(notifications_enabled=await notifications_enabled(db))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings", response_model=SettingsOut)
|
||||||
|
async def put_admin_settings(
|
||||||
|
payload: SettingsOut,
|
||||||
|
current: CurrentUser = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> SettingsOut:
|
||||||
|
row = await db.get(AppSetting, NOTIFICATIONS_ENABLED_KEY)
|
||||||
|
old = await notifications_enabled(db)
|
||||||
|
if row is None:
|
||||||
|
row = AppSetting(
|
||||||
|
key=NOTIFICATIONS_ENABLED_KEY,
|
||||||
|
value="true" if payload.notifications_enabled else "false",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.value = "true" if payload.notifications_enabled else "false"
|
||||||
|
if old != payload.notifications_enabled:
|
||||||
|
audit_event(
|
||||||
|
db,
|
||||||
|
user_id=current.id,
|
||||||
|
action="settings_update",
|
||||||
|
field_name=NOTIFICATIONS_ENABLED_KEY,
|
||||||
|
old_value=old,
|
||||||
|
new_value=payload.notifications_enabled,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return SettingsOut(notifications_enabled=payload.notifications_enabled)
|
||||||
|
|
||||||
|
|
||||||
|
# ── global audit log ─────────────────────────────────────────────────────────
|
||||||
|
class GlobalAuditOut(BaseModel):
|
||||||
|
items: list[AuditEntryOut]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit", response_model=GlobalAuditOut)
|
||||||
|
async def global_audit(
|
||||||
|
ncr_number: str | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=50, ge=1, le=200),
|
||||||
|
_: CurrentUser = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> GlobalAuditOut:
|
||||||
|
stmt = select(AuditLog)
|
||||||
|
if ncr_number:
|
||||||
|
stmt = stmt.where(
|
||||||
|
AuditLog.ncr_id.in_(
|
||||||
|
select(Ncr.id).where(Ncr.ncr_number.like(f"%{ncr_number.strip()}%"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if action:
|
||||||
|
stmt = stmt.where(AuditLog.action == action)
|
||||||
|
total = (
|
||||||
|
await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||||
|
).scalar_one()
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
stmt.order_by(AuditLog.created_at.desc(), AuditLog.id.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return GlobalAuditOut(
|
||||||
|
items=[AuditEntryOut.model_validate(r) for r in rows],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
18
backend/app/routers/health.py
Normal file
18
backend/app/routers/health.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/db")
|
||||||
|
async def health_db(db: AsyncSession = Depends(get_db)) -> dict:
|
||||||
|
await db.execute(text("SELECT 1"))
|
||||||
|
return {"status": "ok", "database": "ok"}
|
||||||
28
backend/app/routers/jobs.py
Normal file
28
backend/app/routers/jobs.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.auth.deps import CurrentUser, get_current_user
|
||||||
|
from app.services.job_lookup import get_job_lookup_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["jobs"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_number}/lookup")
|
||||||
|
async def lookup_job(
|
||||||
|
job_number: str,
|
||||||
|
_: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Job-number enrichment endpoint. Returns {found: false} under the
|
||||||
|
default NullJobLookupService; a future VisualJobLookupService will return
|
||||||
|
part/customer/work-order data from Infor VISUAL without frontend changes."""
|
||||||
|
info = await get_job_lookup_service().lookup(job_number)
|
||||||
|
if info is None:
|
||||||
|
return {"found": False, "job_number": job_number}
|
||||||
|
return {
|
||||||
|
"found": True,
|
||||||
|
"job_number": job_number,
|
||||||
|
"part_id": info.part_id,
|
||||||
|
"part_description": info.part_description,
|
||||||
|
"customer_name": info.customer_name,
|
||||||
|
"work_order_status": info.work_order_status,
|
||||||
|
"source": info.source,
|
||||||
|
}
|
||||||
44
backend/app/routers/lookups.py
Normal file
44
backend/app/routers/lookups.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.deps import CurrentUser, get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Department, DeviationCategory
|
||||||
|
from app.schemas.lookup import LookupsOut, NamedLookupOut
|
||||||
|
|
||||||
|
router = APIRouter(tags=["lookups"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lookups", response_model=LookupsOut)
|
||||||
|
async def get_lookups(
|
||||||
|
_: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> LookupsOut:
|
||||||
|
"""Active departments and deviation categories for form dropdowns."""
|
||||||
|
departments = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Department)
|
||||||
|
.where(Department.is_active.is_(True))
|
||||||
|
.order_by(Department.name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
categories = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(DeviationCategory)
|
||||||
|
.where(DeviationCategory.is_active.is_(True))
|
||||||
|
.order_by(DeviationCategory.name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return LookupsOut(
|
||||||
|
departments=[NamedLookupOut.model_validate(d) for d in departments],
|
||||||
|
deviation_categories=[NamedLookupOut.model_validate(c) for c in categories],
|
||||||
|
)
|
||||||
866
backend/app/routers/ncrs.py
Normal file
866
backend/app/routers/ncrs.py
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
"""NCR endpoints: creation, queues/search, stage actions (the workflow state
|
||||||
|
machine), attachments, audit history, CSV export, and the printable PDF.
|
||||||
|
|
||||||
|
Every stage action re-validates BOTH the caller's role and the NCR's current
|
||||||
|
stage server-side; the frontend's `available_actions` hints are advisory only.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile
|
||||||
|
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||||
|
from sqlalchemy import delete, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.deps import CurrentUser, get_current_user, require_roles
|
||||||
|
from app.database import get_db
|
||||||
|
from app.domain import STAGE_LABELS, Role, Stage
|
||||||
|
from app.models import (
|
||||||
|
Attachment,
|
||||||
|
Department,
|
||||||
|
DeviationCategory,
|
||||||
|
JobInfo,
|
||||||
|
Ncr,
|
||||||
|
NcrSecondaryAssignee,
|
||||||
|
User,
|
||||||
|
UserRole,
|
||||||
|
)
|
||||||
|
from app.models.base import utcnow
|
||||||
|
from app.schemas.ncr import (
|
||||||
|
AttachmentOut,
|
||||||
|
AuditEntryOut,
|
||||||
|
AuditListOut,
|
||||||
|
CostingIn,
|
||||||
|
InitialDispositionIn,
|
||||||
|
InspectionIn,
|
||||||
|
JobInfoOut,
|
||||||
|
NcrCreateIn,
|
||||||
|
NcrDetailOut,
|
||||||
|
NcrListItem,
|
||||||
|
NcrListOut,
|
||||||
|
NcrMutationOut,
|
||||||
|
ReopenIn,
|
||||||
|
SecondaryDispositionIn,
|
||||||
|
TransitionOut,
|
||||||
|
)
|
||||||
|
from app.schemas.user import UserRef
|
||||||
|
from app.services.audit import apply_field_updates, audit_event
|
||||||
|
from app.services.job_lookup import get_job_lookup_service
|
||||||
|
from app.services.notifications import NotifyEvent, send_stage_notification
|
||||||
|
from app.services.numbering import allocate_ncr_number
|
||||||
|
from app.services.sanitize import sanitize_html
|
||||||
|
from app.services.storage import (
|
||||||
|
UploadValidationError,
|
||||||
|
attachment_abs_path,
|
||||||
|
save_attachment,
|
||||||
|
)
|
||||||
|
from app.services.workflow import InvalidTransitionError, record_creation, transition
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["ncrs"])
|
||||||
|
|
||||||
|
_STAGE_ORDER = [
|
||||||
|
Stage.NEW_REQUEST,
|
||||||
|
Stage.SECONDARY_DISPOSITION,
|
||||||
|
Stage.OPERATIONS,
|
||||||
|
Stage.QC_INSPECTION,
|
||||||
|
Stage.COSTING,
|
||||||
|
Stage.CLOSED,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
async def _get_ncr(db: AsyncSession, ncr_id: int) -> Ncr:
|
||||||
|
ncr = await db.get(Ncr, ncr_id)
|
||||||
|
if ncr is None:
|
||||||
|
raise HTTPException(status_code=404, detail="NCR not found.")
|
||||||
|
return ncr
|
||||||
|
|
||||||
|
|
||||||
|
def _days_in_stage(ncr: Ncr) -> int:
|
||||||
|
return max(0, (utcnow() - ncr.stage_entered_at).days)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_stage(ncr: Ncr, expected: Stage) -> None:
|
||||||
|
if ncr.stage == Stage.CLOSED.value and expected != Stage.CLOSED:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"{ncr.ncr_number} is closed and locked. Only an Admin can reopen it.",
|
||||||
|
)
|
||||||
|
if ncr.stage != expected.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"{ncr.ncr_number} is in stage '{STAGE_LABELS[Stage(ncr.stage)]}', "
|
||||||
|
f"but this action requires '{STAGE_LABELS[expected]}'."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_secondary_assignee(ncr: Ncr, current: CurrentUser) -> bool:
|
||||||
|
return any(row.user_id == current.id for row in ncr.secondary_assignee_rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _available_actions(ncr: Ncr, current: CurrentUser) -> list[str]:
|
||||||
|
actions: list[str] = []
|
||||||
|
stage = Stage(ncr.stage)
|
||||||
|
if stage == Stage.NEW_REQUEST and current.has_role(Role.DISPOSITION_AUTHORITY):
|
||||||
|
actions.append("initial_disposition")
|
||||||
|
if stage == Stage.SECONDARY_DISPOSITION and (
|
||||||
|
current.is_admin or _is_secondary_assignee(ncr, current)
|
||||||
|
):
|
||||||
|
actions.append("secondary_disposition")
|
||||||
|
if stage == Stage.OPERATIONS and current.has_role(Role.OPERATIONS):
|
||||||
|
actions.append("operations_complete")
|
||||||
|
if stage == Stage.QC_INSPECTION and current.has_role(Role.QC_INSPECTOR):
|
||||||
|
actions.append("inspection")
|
||||||
|
if stage == Stage.COSTING and current.has_role(Role.COSTING):
|
||||||
|
actions.append("costing")
|
||||||
|
if stage == Stage.CLOSED and current.is_admin:
|
||||||
|
actions.append("reopen")
|
||||||
|
if stage != Stage.CLOSED:
|
||||||
|
actions.append("add_attachment")
|
||||||
|
if current.has_role(Role.QC_INSPECTOR): # admins pass automatically
|
||||||
|
actions.append("view_audit")
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def _detail(ncr: Ncr, current: CurrentUser) -> NcrDetailOut:
|
||||||
|
stage = Stage(ncr.stage)
|
||||||
|
return NcrDetailOut(
|
||||||
|
id=ncr.id,
|
||||||
|
ncr_number=ncr.ncr_number,
|
||||||
|
job_number=ncr.job_number,
|
||||||
|
created_at=ncr.created_at,
|
||||||
|
stage=stage.value,
|
||||||
|
stage_label=STAGE_LABELS[stage],
|
||||||
|
stage_entered_at=ncr.stage_entered_at,
|
||||||
|
days_in_stage=_days_in_stage(ncr),
|
||||||
|
department=ncr.department.name,
|
||||||
|
department_id=ncr.department_id,
|
||||||
|
deviation_category=ncr.deviation_category.name,
|
||||||
|
deviation_category_id=ncr.deviation_category_id,
|
||||||
|
deviation_detail=ncr.deviation_detail,
|
||||||
|
requester=UserRef.model_validate(ncr.requester),
|
||||||
|
disposition_authority=UserRef.model_validate(ncr.disposition_authority),
|
||||||
|
qc_authority=ncr.qc_authority,
|
||||||
|
work_order=ncr.work_order,
|
||||||
|
disposition_notes=ncr.disposition_notes,
|
||||||
|
secondary_review_needed=ncr.secondary_review_needed,
|
||||||
|
secondary_authorities=[
|
||||||
|
UserRef.model_validate(u) for u in ncr.secondary_authorities
|
||||||
|
],
|
||||||
|
operations_complete=ncr.operations_complete,
|
||||||
|
operations_completed_at=ncr.operations_completed_at,
|
||||||
|
operations_completed_by=(
|
||||||
|
UserRef.model_validate(ncr.operations_completed_by)
|
||||||
|
if ncr.operations_completed_by
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
qc_approval=ncr.qc_approval,
|
||||||
|
inspection_notes=ncr.inspection_notes,
|
||||||
|
qc_closed=ncr.qc_closed,
|
||||||
|
qc_closed_at=ncr.qc_closed_at,
|
||||||
|
qc_closed_by=(
|
||||||
|
UserRef.model_validate(ncr.qc_closed_by) if ncr.qc_closed_by else None
|
||||||
|
),
|
||||||
|
labor_cost=ncr.labor_cost,
|
||||||
|
material_cost=ncr.material_cost,
|
||||||
|
service_cost=ncr.service_cost,
|
||||||
|
other_cost=ncr.other_cost,
|
||||||
|
total_cost=ncr.total_cost,
|
||||||
|
costing_completed_at=ncr.costing_completed_at,
|
||||||
|
costing_completed_by=(
|
||||||
|
UserRef.model_validate(ncr.costing_completed_by)
|
||||||
|
if ncr.costing_completed_by
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
closed_at=ncr.closed_at,
|
||||||
|
closed_by=UserRef.model_validate(ncr.closed_by) if ncr.closed_by else None,
|
||||||
|
job_info=JobInfoOut.model_validate(ncr.job_info) if ncr.job_info else None,
|
||||||
|
attachments=[AttachmentOut.model_validate(a) for a in ncr.attachments],
|
||||||
|
transitions=[TransitionOut.model_validate(t) for t in ncr.transitions],
|
||||||
|
available_actions=_available_actions(ncr, current),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refetch(db: AsyncSession, ncr_id: int) -> Ncr:
|
||||||
|
"""Reload the NCR with fresh relationship collections after a commit."""
|
||||||
|
db.expire_all()
|
||||||
|
return await _get_ncr(db, ncr_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── create ───────────────────────────────────────────────────────────────────
|
||||||
|
@router.post("/ncrs", response_model=NcrMutationOut, status_code=201)
|
||||||
|
async def create_ncr(
|
||||||
|
payload: NcrCreateIn,
|
||||||
|
current: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NcrMutationOut:
|
||||||
|
"""Stage 1 — New Request. Open to every authenticated user."""
|
||||||
|
dept = await db.get(Department, payload.department_id)
|
||||||
|
if dept is None or not dept.is_active:
|
||||||
|
raise HTTPException(status_code=422, detail="Unknown or inactive department.")
|
||||||
|
cat = await db.get(DeviationCategory, payload.deviation_category_id)
|
||||||
|
if cat is None or not cat.is_active:
|
||||||
|
raise HTTPException(status_code=422, detail="Unknown or inactive deviation category.")
|
||||||
|
authority = await db.get(User, payload.disposition_authority_id)
|
||||||
|
if (
|
||||||
|
authority is None
|
||||||
|
or not authority.is_active
|
||||||
|
or Role.DISPOSITION_AUTHORITY.value not in authority.roles
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Selected disposition authority does not hold the Disposition Authority role.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# External enrichment BEFORE the numbering lock so a slow ERP lookup can
|
||||||
|
# never serialize submissions. NullJobLookupService returns instantly.
|
||||||
|
job_info_data = None
|
||||||
|
try:
|
||||||
|
job_info_data = await get_job_lookup_service().lookup(payload.job_number)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Job lookup failed for %s (non-blocking)", payload.job_number)
|
||||||
|
|
||||||
|
ncr_number, year, seq = await allocate_ncr_number(db)
|
||||||
|
ncr = Ncr(
|
||||||
|
ncr_number=ncr_number,
|
||||||
|
ncr_year=year,
|
||||||
|
ncr_seq=seq,
|
||||||
|
job_number=payload.job_number.strip(),
|
||||||
|
department_id=payload.department_id,
|
||||||
|
deviation_category_id=payload.deviation_category_id,
|
||||||
|
disposition_authority_id=payload.disposition_authority_id,
|
||||||
|
deviation_detail=payload.deviation_detail,
|
||||||
|
requester_id=current.id,
|
||||||
|
stage=Stage.NEW_REQUEST.value,
|
||||||
|
)
|
||||||
|
db.add(ncr)
|
||||||
|
await db.flush()
|
||||||
|
record_creation(db, ncr, current.id)
|
||||||
|
if job_info_data is not None:
|
||||||
|
db.add(
|
||||||
|
JobInfo(
|
||||||
|
ncr_id=ncr.id,
|
||||||
|
part_id=job_info_data.part_id,
|
||||||
|
part_description=job_info_data.part_description,
|
||||||
|
customer_name=job_info_data.customer_name,
|
||||||
|
work_order_status=job_info_data.work_order_status,
|
||||||
|
source=job_info_data.source,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
ncr = await _refetch(db, ncr.id)
|
||||||
|
warnings = await send_stage_notification(
|
||||||
|
db,
|
||||||
|
ncr,
|
||||||
|
NotifyEvent.CREATED,
|
||||||
|
current,
|
||||||
|
f"{current.user.display_name} submitted a new NCR and selected you as the "
|
||||||
|
"disposition authority.",
|
||||||
|
)
|
||||||
|
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||||
|
|
||||||
|
|
||||||
|
# ── queues / search / export ────────────────────────────────────────────────
|
||||||
|
def _apply_filters(
|
||||||
|
stmt,
|
||||||
|
*,
|
||||||
|
q: str | None,
|
||||||
|
job_number: str | None,
|
||||||
|
department_id: int | None,
|
||||||
|
category_id: int | None,
|
||||||
|
stage: str | None,
|
||||||
|
date_from: str | None,
|
||||||
|
date_to: str | None,
|
||||||
|
disposition_authority_id: int | None,
|
||||||
|
):
|
||||||
|
if q:
|
||||||
|
like = f"%{q.strip()}%"
|
||||||
|
stmt = stmt.where(or_(Ncr.ncr_number.like(like), Ncr.job_number.like(like)))
|
||||||
|
if job_number:
|
||||||
|
stmt = stmt.where(Ncr.job_number.like(f"%{job_number.strip()}%"))
|
||||||
|
if department_id:
|
||||||
|
stmt = stmt.where(Ncr.department_id == department_id)
|
||||||
|
if category_id:
|
||||||
|
stmt = stmt.where(Ncr.deviation_category_id == category_id)
|
||||||
|
if stage:
|
||||||
|
stmt = stmt.where(Ncr.stage == stage)
|
||||||
|
if date_from:
|
||||||
|
stmt = stmt.where(Ncr.created_at >= date_from)
|
||||||
|
if date_to:
|
||||||
|
stmt = stmt.where(Ncr.created_at <= f"{date_to} 23:59:59")
|
||||||
|
if disposition_authority_id:
|
||||||
|
stmt = stmt.where(Ncr.disposition_authority_id == disposition_authority_id)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_filter(stmt, queue: str, current: CurrentUser):
|
||||||
|
if queue == "my_requests":
|
||||||
|
return stmt.where(Ncr.requester_id == current.id)
|
||||||
|
if queue == "new_requests":
|
||||||
|
return stmt.where(Ncr.stage == Stage.NEW_REQUEST.value)
|
||||||
|
if queue == "secondary":
|
||||||
|
return stmt.where(
|
||||||
|
Ncr.stage == Stage.SECONDARY_DISPOSITION.value,
|
||||||
|
Ncr.id.in_(
|
||||||
|
select(NcrSecondaryAssignee.ncr_id).where(
|
||||||
|
NcrSecondaryAssignee.user_id == current.id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if queue == "operations":
|
||||||
|
return stmt.where(Ncr.stage == Stage.OPERATIONS.value)
|
||||||
|
if queue == "inspection":
|
||||||
|
return stmt.where(Ncr.stage == Stage.QC_INSPECTION.value)
|
||||||
|
if queue == "costing":
|
||||||
|
return stmt.where(Ncr.stage == Stage.COSTING.value)
|
||||||
|
if queue == "recently_closed":
|
||||||
|
return stmt.where(Ncr.stage == Stage.CLOSED.value)
|
||||||
|
if queue in ("all", ""):
|
||||||
|
return stmt
|
||||||
|
raise HTTPException(status_code=422, detail=f"Unknown queue '{queue}'.")
|
||||||
|
|
||||||
|
|
||||||
|
def _list_item(ncr: Ncr) -> NcrListItem:
|
||||||
|
return NcrListItem(
|
||||||
|
id=ncr.id,
|
||||||
|
ncr_number=ncr.ncr_number,
|
||||||
|
job_number=ncr.job_number,
|
||||||
|
department=ncr.department.name,
|
||||||
|
deviation_category=ncr.deviation_category.name,
|
||||||
|
requester=ncr.requester.display_name,
|
||||||
|
disposition_authority=ncr.disposition_authority.display_name,
|
||||||
|
stage=ncr.stage,
|
||||||
|
stage_label=STAGE_LABELS[Stage(ncr.stage)],
|
||||||
|
days_in_stage=_days_in_stage(ncr),
|
||||||
|
created_at=ncr.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ncrs", response_model=NcrListOut)
|
||||||
|
async def list_ncrs(
|
||||||
|
queue: str = Query(default="all"),
|
||||||
|
q: str | None = None,
|
||||||
|
job_number: str | None = None,
|
||||||
|
department_id: int | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
|
stage: str | None = None,
|
||||||
|
date_from: str | None = Query(default=None, description="YYYY-MM-DD"),
|
||||||
|
date_to: str | None = Query(default=None, description="YYYY-MM-DD"),
|
||||||
|
disposition_authority_id: int | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=200),
|
||||||
|
current: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NcrListOut:
|
||||||
|
stmt = select(Ncr)
|
||||||
|
stmt = _queue_filter(stmt, queue, current)
|
||||||
|
stmt = _apply_filters(
|
||||||
|
stmt,
|
||||||
|
q=q,
|
||||||
|
job_number=job_number,
|
||||||
|
department_id=department_id,
|
||||||
|
category_id=category_id,
|
||||||
|
stage=stage,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
disposition_authority_id=disposition_authority_id,
|
||||||
|
)
|
||||||
|
total = (
|
||||||
|
await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||||
|
).scalar_one()
|
||||||
|
order = Ncr.closed_at.desc() if queue == "recently_closed" else Ncr.created_at.desc()
|
||||||
|
rows = (
|
||||||
|
(await db.execute(stmt.order_by(order).offset((page - 1) * page_size).limit(page_size)))
|
||||||
|
.scalars()
|
||||||
|
.unique()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return NcrListOut(
|
||||||
|
items=[_list_item(n) for n in rows], total=total, page=page, page_size=page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CSV_COLUMNS = [
|
||||||
|
"ncr_number", "job_number", "department", "deviation_category", "requester",
|
||||||
|
"disposition_authority", "stage", "days_in_stage", "created_at", "work_order",
|
||||||
|
"qc_authority", "secondary_review_needed", "operations_complete", "qc_approval",
|
||||||
|
"qc_closed", "labor_cost", "material_cost", "service_cost", "other_cost",
|
||||||
|
"total_cost", "closed_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ncrs/export.csv")
|
||||||
|
async def export_ncrs_csv(
|
||||||
|
queue: str = Query(default="all"),
|
||||||
|
q: str | None = None,
|
||||||
|
job_number: str | None = None,
|
||||||
|
department_id: int | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
|
stage: str | None = None,
|
||||||
|
date_from: str | None = None,
|
||||||
|
date_to: str | None = None,
|
||||||
|
disposition_authority_id: int | None = None,
|
||||||
|
current: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""CSV export of any queue/search view (same filters as GET /ncrs)."""
|
||||||
|
stmt = select(Ncr)
|
||||||
|
stmt = _queue_filter(stmt, queue, current)
|
||||||
|
stmt = _apply_filters(
|
||||||
|
stmt,
|
||||||
|
q=q,
|
||||||
|
job_number=job_number,
|
||||||
|
department_id=department_id,
|
||||||
|
category_id=category_id,
|
||||||
|
stage=stage,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
disposition_authority_id=disposition_authority_id,
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
(await db.execute(stmt.order_by(Ncr.created_at.desc()).limit(20000)))
|
||||||
|
.scalars()
|
||||||
|
.unique()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(_CSV_COLUMNS)
|
||||||
|
for n in rows:
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
n.ncr_number, n.job_number, n.department.name, n.deviation_category.name,
|
||||||
|
n.requester.display_name, n.disposition_authority.display_name,
|
||||||
|
STAGE_LABELS[Stage(n.stage)], _days_in_stage(n),
|
||||||
|
n.created_at.isoformat(sep=" "), n.work_order or "", n.qc_authority or "",
|
||||||
|
n.secondary_review_needed, n.operations_complete, n.qc_approval or "",
|
||||||
|
n.qc_closed, n.labor_cost or "", n.material_cost or "",
|
||||||
|
n.service_cost or "", n.other_cost or "", n.total_cost or "",
|
||||||
|
n.closed_at.isoformat(sep=" ") if n.closed_at else "",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
buf.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([buf.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="ncr-export.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ncrs/{ncr_id}", response_model=NcrDetailOut)
|
||||||
|
async def get_ncr(
|
||||||
|
ncr_id: int,
|
||||||
|
current: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NcrDetailOut:
|
||||||
|
ncr = await _get_ncr(db, ncr_id)
|
||||||
|
return _detail(ncr, current)
|
||||||
|
|
||||||
|
|
||||||
|
# ── stage actions ────────────────────────────────────────────────────────────
|
||||||
|
@router.post("/ncrs/{ncr_id}/initial-disposition", response_model=NcrMutationOut)
|
||||||
|
async def initial_disposition(
|
||||||
|
ncr_id: int,
|
||||||
|
payload: InitialDispositionIn,
|
||||||
|
current: CurrentUser = Depends(require_roles(Role.DISPOSITION_AUTHORITY)),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NcrMutationOut:
|
||||||
|
"""Stage 2 — Initial Disposition, performed on a New Request. Routes to
|
||||||
|
Secondary Disposition (when secondary review is needed) or Operations."""
|
||||||
|
ncr = await _get_ncr(db, ncr_id)
|
||||||
|
_ensure_stage(ncr, Stage.NEW_REQUEST)
|
||||||
|
|
||||||
|
assignees: list[User] = []
|
||||||
|
if payload.secondary_review_needed:
|
||||||
|
if not payload.secondary_authority_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Secondary review requires at least one person in 'Notify These People'.",
|
||||||
|
)
|
||||||
|
for uid in set(payload.secondary_authority_ids):
|
||||||
|
u = await db.get(User, uid)
|
||||||
|
if (
|
||||||
|
u is None
|
||||||
|
or not u.is_active
|
||||||
|
or Role.SECONDARY_DISPOSITION_AUTHORITY.value not in u.roles
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="All selected people must hold the Secondary Disposition Authority role.",
|
||||||
|
)
|
||||||
|
assignees.append(u)
|
||||||
|
|
||||||
|
updates = payload.model_dump(exclude_unset=True, exclude={"secondary_authority_ids"})
|
||||||
|
if "disposition_notes" in updates:
|
||||||
|
updates["disposition_notes"] = sanitize_html(updates["disposition_notes"])
|
||||||
|
updates["secondary_review_needed"] = payload.secondary_review_needed
|
||||||
|
apply_field_updates(db, ncr, current.id, updates, action="initial_disposition")
|
||||||
|
|
||||||
|
if payload.secondary_review_needed:
|
||||||
|
await db.execute(
|
||||||
|
delete(NcrSecondaryAssignee).where(NcrSecondaryAssignee.ncr_id == ncr.id)
|
||||||
|
)
|
||||||
|
for u in assignees:
|
||||||
|
db.add(NcrSecondaryAssignee(ncr_id=ncr.id, user_id=u.id))
|
||||||
|
audit_event(
|
||||||
|
db,
|
||||||
|
ncr_id=ncr.id,
|
||||||
|
user_id=current.id,
|
||||||
|
action="initial_disposition",
|
||||||
|
field_name="secondary_authorities",
|
||||||
|
new_value=", ".join(u.display_name for u in assignees),
|
||||||
|
)
|
||||||
|
_do_transition(db, ncr, Stage.SECONDARY_DISPOSITION, "initial_disposition", current)
|
||||||
|
event, summary = (
|
||||||
|
NotifyEvent.SECONDARY_ASSIGNED,
|
||||||
|
f"{current.user.display_name} completed initial disposition and assigned "
|
||||||
|
"you for secondary disposition review.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_do_transition(db, ncr, Stage.OPERATIONS, "initial_disposition", current)
|
||||||
|
event, summary = (
|
||||||
|
NotifyEvent.RELEASED_TO_OPERATIONS,
|
||||||
|
f"{current.user.display_name} completed initial disposition; the NCR is "
|
||||||
|
"ready for Operations.",
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
ncr = await _refetch(db, ncr.id)
|
||||||
|
warnings = await send_stage_notification(db, ncr, event, current, summary)
|
||||||
|
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ncrs/{ncr_id}/secondary-disposition", response_model=NcrMutationOut)
|
||||||
|
async def secondary_disposition(
|
||||||
|
ncr_id: int,
|
||||||
|
payload: SecondaryDispositionIn,
|
||||||
|
current: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NcrMutationOut:
|
||||||
|
"""Stage 3 — Secondary Disposition. Only the assigned secondary
|
||||||
|
authorities (or an Admin) may update or release to Operations."""
|
||||||
|
ncr = await _get_ncr(db, ncr_id)
|
||||||
|
_ensure_stage(ncr, Stage.SECONDARY_DISPOSITION)
|
||||||
|
if not (current.is_admin or _is_secondary_assignee(ncr, current)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Only the assigned secondary disposition authority can act on this NCR.",
|
||||||
|
)
|
||||||
|
|
||||||
|
updates = payload.model_dump(exclude_unset=True, exclude={"release"})
|
||||||
|
if "disposition_notes" in updates:
|
||||||
|
updates["disposition_notes"] = sanitize_html(updates["disposition_notes"])
|
||||||
|
apply_field_updates(db, ncr, current.id, updates, action="secondary_disposition")
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
if payload.release:
|
||||||
|
_do_transition(db, ncr, Stage.OPERATIONS, "secondary_release", current)
|
||||||
|
await db.commit()
|
||||||
|
ncr = await _refetch(db, ncr.id)
|
||||||
|
warnings = await send_stage_notification(
|
||||||
|
db,
|
||||||
|
ncr,
|
||||||
|
NotifyEvent.RELEASED_TO_OPERATIONS,
|
||||||
|
current,
|
||||||
|
f"{current.user.display_name} completed secondary disposition review and "
|
||||||
|
"released the NCR to Operations.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await db.commit()
|
||||||
|
ncr = await _refetch(db, ncr.id)
|
||||||
|
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ncrs/{ncr_id}/operations-complete", response_model=NcrMutationOut)
|
||||||
|
async def operations_complete(
|
||||||
|
ncr_id: int,
|
||||||
|
current: CurrentUser = Depends(require_roles(Role.OPERATIONS)),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NcrMutationOut:
|
||||||
|
"""Stage 4 — Operations marks rework complete; NCR moves to QC Inspection."""
|
||||||
|
ncr = await _get_ncr(db, ncr_id)
|
||||||
|
_ensure_stage(ncr, Stage.OPERATIONS)
|
||||||
|
|
||||||
|
apply_field_updates(
|
||||||
|
db,
|
||||||
|
ncr,
|
||||||
|
current.id,
|
||||||
|
{
|
||||||
|
"operations_complete": True,
|
||||||
|
"operations_completed_at": utcnow(),
|
||||||
|
"operations_completed_by_id": current.id,
|
||||||
|
},
|
||||||
|
action="operations_complete",
|
||||||
|
)
|
||||||
|
_do_transition(db, ncr, Stage.QC_INSPECTION, "operations_complete", current)
|
||||||
|
await db.commit()
|
||||||
|
ncr = await _refetch(db, ncr.id)
|
||||||
|
warnings = await send_stage_notification(
|
||||||
|
db,
|
||||||
|
ncr,
|
||||||
|
NotifyEvent.OPERATIONS_COMPLETE,
|
||||||
|
current,
|
||||||
|
f"{current.user.display_name} marked operations complete; the NCR is ready "
|
||||||
|
"for QC inspection.",
|
||||||
|
)
|
||||||
|
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ncrs/{ncr_id}/inspection", response_model=NcrMutationOut)
|
||||||
|
async def inspection(
|
||||||
|
ncr_id: int,
|
||||||
|
payload: InspectionIn,
|
||||||
|
current: CurrentUser = Depends(require_roles(Role.QC_INSPECTOR)),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NcrMutationOut:
|
||||||
|
"""Stage 5 — QC Inspection. QC can save repeatedly; checking QC Closed
|
||||||
|
advances the NCR to Costing."""
|
||||||
|
ncr = await _get_ncr(db, ncr_id)
|
||||||
|
_ensure_stage(ncr, Stage.QC_INSPECTION)
|
||||||
|
|
||||||
|
updates = payload.model_dump(exclude_unset=True, exclude={"qc_closed"})
|
||||||
|
if payload.qc_closed:
|
||||||
|
updates.update(
|
||||||
|
{"qc_closed": True, "qc_closed_at": utcnow(), "qc_closed_by_id": current.id}
|
||||||
|
)
|
||||||
|
apply_field_updates(db, ncr, current.id, updates, action="inspection")
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
if payload.qc_closed:
|
||||||
|
_do_transition(db, ncr, Stage.COSTING, "qc_close", current)
|
||||||
|
await db.commit()
|
||||||
|
ncr = await _refetch(db, ncr.id)
|
||||||
|
warnings = await send_stage_notification(
|
||||||
|
db,
|
||||||
|
ncr,
|
||||||
|
NotifyEvent.QC_CLOSED,
|
||||||
|
current,
|
||||||
|
f"{current.user.display_name} closed QC inspection; the NCR is awaiting costing.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await db.commit()
|
||||||
|
ncr = await _refetch(db, ncr.id)
|
||||||
|
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ncrs/{ncr_id}/costing", response_model=NcrMutationOut)
|
||||||
|
async def costing(
|
||||||
|
ncr_id: int,
|
||||||
|
payload: CostingIn,
|
||||||
|
current: CurrentUser = Depends(require_roles(Role.COSTING)),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NcrMutationOut:
|
||||||
|
"""Stage 6 — Costing. Saving costs completes the workflow and closes the NCR."""
|
||||||
|
ncr = await _get_ncr(db, ncr_id)
|
||||||
|
_ensure_stage(ncr, Stage.COSTING)
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
apply_field_updates(
|
||||||
|
db,
|
||||||
|
ncr,
|
||||||
|
current.id,
|
||||||
|
{
|
||||||
|
"labor_cost": payload.labor_cost,
|
||||||
|
"material_cost": payload.material_cost,
|
||||||
|
"service_cost": payload.service_cost,
|
||||||
|
"other_cost": payload.other_cost,
|
||||||
|
"costing_completed_at": now,
|
||||||
|
"costing_completed_by_id": current.id,
|
||||||
|
"closed_at": now,
|
||||||
|
"closed_by_id": current.id,
|
||||||
|
},
|
||||||
|
action="costing",
|
||||||
|
)
|
||||||
|
_do_transition(db, ncr, Stage.CLOSED, "complete_costing", current)
|
||||||
|
await db.commit()
|
||||||
|
ncr = await _refetch(db, ncr.id)
|
||||||
|
warnings = await send_stage_notification(
|
||||||
|
db,
|
||||||
|
ncr,
|
||||||
|
NotifyEvent.CLOSED,
|
||||||
|
current,
|
||||||
|
f"Costing is complete and your NCR has been closed. Total cost of "
|
||||||
|
f"nonconformance: ${ncr.total_cost:,.2f}.",
|
||||||
|
)
|
||||||
|
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ncrs/{ncr_id}/reopen", response_model=NcrMutationOut)
|
||||||
|
async def reopen(
|
||||||
|
ncr_id: int,
|
||||||
|
payload: ReopenIn,
|
||||||
|
current: CurrentUser = Depends(require_roles(Role.ADMIN)),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NcrMutationOut:
|
||||||
|
"""Admin-only: reopen a closed NCR into a chosen prior stage. The reason
|
||||||
|
is required and recorded in the audit trail and transition history."""
|
||||||
|
ncr = await _get_ncr(db, ncr_id)
|
||||||
|
if ncr.stage != Stage.CLOSED.value:
|
||||||
|
raise HTTPException(status_code=409, detail="Only closed NCRs can be reopened.")
|
||||||
|
if payload.to_stage == Stage.SECONDARY_DISPOSITION and not ncr.secondary_assignee_rows:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="This NCR has no secondary authorities assigned; reopen it to "
|
||||||
|
"New Request so a disposition authority can assign them.",
|
||||||
|
)
|
||||||
|
|
||||||
|
target_idx = _STAGE_ORDER.index(payload.to_stage)
|
||||||
|
resets: dict = {"closed_at": None, "closed_by_id": None}
|
||||||
|
if target_idx <= _STAGE_ORDER.index(Stage.OPERATIONS):
|
||||||
|
resets.update(
|
||||||
|
{
|
||||||
|
"operations_complete": False,
|
||||||
|
"operations_completed_at": None,
|
||||||
|
"operations_completed_by_id": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if target_idx <= _STAGE_ORDER.index(Stage.QC_INSPECTION):
|
||||||
|
resets.update({"qc_closed": False, "qc_closed_at": None, "qc_closed_by_id": None})
|
||||||
|
if target_idx <= _STAGE_ORDER.index(Stage.COSTING):
|
||||||
|
resets.update({"costing_completed_at": None, "costing_completed_by_id": None})
|
||||||
|
apply_field_updates(db, ncr, current.id, resets, action="reopen")
|
||||||
|
_do_transition(
|
||||||
|
db, ncr, payload.to_stage, "reopen", current, note=f"Reopen reason: {payload.reason}"
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
ncr = await _refetch(db, ncr.id)
|
||||||
|
warnings = await send_stage_notification(
|
||||||
|
db,
|
||||||
|
ncr,
|
||||||
|
NotifyEvent.REOPENED,
|
||||||
|
current,
|
||||||
|
f"{current.user.display_name} reopened this NCR to "
|
||||||
|
f"'{STAGE_LABELS[payload.to_stage]}'. Reason: {payload.reason}",
|
||||||
|
)
|
||||||
|
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_transition(
|
||||||
|
db: AsyncSession,
|
||||||
|
ncr: Ncr,
|
||||||
|
to_stage: Stage,
|
||||||
|
action: str,
|
||||||
|
current: CurrentUser,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
transition(db, ncr, to_stage, action=action, actor_id=current.id, note=note)
|
||||||
|
except InvalidTransitionError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ── attachments ──────────────────────────────────────────────────────────────
|
||||||
|
@router.post("/ncrs/{ncr_id}/attachments", response_model=list[AttachmentOut], status_code=201)
|
||||||
|
async def upload_attachments(
|
||||||
|
ncr_id: int,
|
||||||
|
files: list[UploadFile],
|
||||||
|
current: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[AttachmentOut]:
|
||||||
|
"""Photo/file attachments (multiple per request; camera capture on
|
||||||
|
tablets posts here too). Blocked once the NCR is closed."""
|
||||||
|
ncr = await _get_ncr(db, ncr_id)
|
||||||
|
if ncr.stage == Stage.CLOSED.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409, detail="This NCR is closed; attachments are locked."
|
||||||
|
)
|
||||||
|
if not files:
|
||||||
|
raise HTTPException(status_code=422, detail="No files provided.")
|
||||||
|
|
||||||
|
saved: list[Attachment] = []
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
meta = await save_attachment(f, ncr.id)
|
||||||
|
except UploadValidationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
att = Attachment(ncr_id=ncr.id, uploaded_by_id=current.id, **meta)
|
||||||
|
db.add(att)
|
||||||
|
audit_event(
|
||||||
|
db,
|
||||||
|
ncr_id=ncr.id,
|
||||||
|
user_id=current.id,
|
||||||
|
action="attachment_add",
|
||||||
|
field_name="attachments",
|
||||||
|
new_value=meta["original_filename"],
|
||||||
|
detail=f"{meta['size_bytes']} bytes, {meta['content_type']}",
|
||||||
|
)
|
||||||
|
saved.append(att)
|
||||||
|
await db.commit()
|
||||||
|
for att in saved:
|
||||||
|
await db.refresh(att)
|
||||||
|
return [AttachmentOut.model_validate(a) for a in saved]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/attachments/{attachment_id}/download")
|
||||||
|
async def download_attachment(
|
||||||
|
attachment_id: int,
|
||||||
|
_: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> FileResponse:
|
||||||
|
att = await db.get(Attachment, attachment_id)
|
||||||
|
if att is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Attachment not found.")
|
||||||
|
path = attachment_abs_path(att.stored_path)
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Attachment file missing from storage.")
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type=att.content_type,
|
||||||
|
filename=att.original_filename,
|
||||||
|
content_disposition_type="inline" if att.is_image else "attachment",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── audit history ────────────────────────────────────────────────────────────
|
||||||
|
@router.get("/ncrs/{ncr_id}/audit", response_model=AuditListOut)
|
||||||
|
async def ncr_audit(
|
||||||
|
ncr_id: int,
|
||||||
|
current: CurrentUser = Depends(require_roles(Role.QC_INSPECTOR)),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> AuditListOut:
|
||||||
|
"""Audit History tab — Admin and QC roles."""
|
||||||
|
from app.models import AuditLog
|
||||||
|
|
||||||
|
await _get_ncr(db, ncr_id)
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(AuditLog)
|
||||||
|
.where(AuditLog.ncr_id == ncr_id)
|
||||||
|
.order_by(AuditLog.created_at.desc(), AuditLog.id.desc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return AuditListOut(
|
||||||
|
items=[AuditEntryOut.model_validate(r) for r in rows], total=len(rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── printable PDF ────────────────────────────────────────────────────────────
|
||||||
|
@router.get("/ncrs/{ncr_id}/pdf")
|
||||||
|
async def ncr_pdf(
|
||||||
|
ncr_id: int,
|
||||||
|
current: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> Response:
|
||||||
|
"""Clean single-document rendering of the complete NCR for hard-copy
|
||||||
|
travelers and audits."""
|
||||||
|
from app.services.pdf import render_ncr_pdf
|
||||||
|
|
||||||
|
ncr = await _get_ncr(db, ncr_id)
|
||||||
|
pdf_bytes = await render_ncr_pdf(ncr)
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'inline; filename="{ncr.ncr_number}.pdf"'
|
||||||
|
},
|
||||||
|
)
|
||||||
185
backend/app/routers/reports.py
Normal file
185
backend/app/routers/reports.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""Built-in reports: counts, cost of nonconformance, aging, cycle times,
|
||||||
|
top jobs. All queries respect the shared date-range/department/category
|
||||||
|
filters."""
|
||||||
|
from collections import defaultdict
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.deps import CurrentUser, get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.domain import STAGE_LABELS, Stage
|
||||||
|
from app.models import Department, DeviationCategory, Ncr, StageTransition
|
||||||
|
from app.models.base import utcnow
|
||||||
|
from app.schemas.report import (
|
||||||
|
AgingBucket,
|
||||||
|
CostByMonth,
|
||||||
|
CountByMonth,
|
||||||
|
CountByName,
|
||||||
|
ReportsSummaryOut,
|
||||||
|
StageCycleTime,
|
||||||
|
TopJob,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["reports"])
|
||||||
|
|
||||||
|
_AGING_BUCKETS = [(0, 7, "0–7 days"), (8, 14, "8–14 days"), (15, 30, "15–30 days"),
|
||||||
|
(31, 60, "31–60 days"), (61, None, "60+ days")]
|
||||||
|
|
||||||
|
|
||||||
|
def _base_filters(stmt, date_from, date_to, department_id, category_id):
|
||||||
|
if date_from:
|
||||||
|
stmt = stmt.where(Ncr.created_at >= date_from)
|
||||||
|
if date_to:
|
||||||
|
stmt = stmt.where(Ncr.created_at <= f"{date_to} 23:59:59")
|
||||||
|
if department_id:
|
||||||
|
stmt = stmt.where(Ncr.department_id == department_id)
|
||||||
|
if category_id:
|
||||||
|
stmt = stmt.where(Ncr.deviation_category_id == category_id)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports/summary", response_model=ReportsSummaryOut)
|
||||||
|
async def reports_summary(
|
||||||
|
date_from: str | None = Query(default=None, description="YYYY-MM-DD"),
|
||||||
|
date_to: str | None = Query(default=None, description="YYYY-MM-DD"),
|
||||||
|
department_id: int | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
|
_: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ReportsSummaryOut:
|
||||||
|
filters = dict(
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
department_id=department_id,
|
||||||
|
category_id=category_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load the filtered NCR set once; aggregate in Python. NCR volume is a few
|
||||||
|
# thousand rows a year, so this stays cheap and keeps the SQL portable.
|
||||||
|
ncrs = (
|
||||||
|
(await db.execute(_base_filters(select(Ncr), **filters))).scalars().unique().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
dept_names = {
|
||||||
|
d.id: d.name for d in (await db.execute(select(Department))).scalars().all()
|
||||||
|
}
|
||||||
|
cat_names = {
|
||||||
|
c.id: c.name
|
||||||
|
for c in (await db.execute(select(DeviationCategory))).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
by_dept: dict[str, int] = defaultdict(int)
|
||||||
|
by_cat: dict[str, int] = defaultdict(int)
|
||||||
|
by_month: dict[str, int] = defaultdict(int)
|
||||||
|
cost_by_month: dict[str, dict[str, Decimal]] = defaultdict(
|
||||||
|
lambda: {"labor": Decimal(0), "material": Decimal(0), "service": Decimal(0), "other": Decimal(0)}
|
||||||
|
)
|
||||||
|
aging_counts: dict[str, int] = {label: 0 for _, _, label in _AGING_BUCKETS}
|
||||||
|
job_counts: dict[str, int] = defaultdict(int)
|
||||||
|
total_cost = Decimal(0)
|
||||||
|
open_count = 0
|
||||||
|
closed_count = 0
|
||||||
|
now = utcnow()
|
||||||
|
|
||||||
|
for n in ncrs:
|
||||||
|
by_dept[dept_names.get(n.department_id, "?")] += 1
|
||||||
|
by_cat[cat_names.get(n.deviation_category_id, "?")] += 1
|
||||||
|
by_month[n.created_at.strftime("%Y-%m")] += 1
|
||||||
|
job_counts[n.job_number] += 1
|
||||||
|
if n.stage == Stage.CLOSED.value:
|
||||||
|
closed_count += 1
|
||||||
|
month = (n.closed_at or n.created_at).strftime("%Y-%m")
|
||||||
|
bucket = cost_by_month[month]
|
||||||
|
bucket["labor"] += n.labor_cost or 0
|
||||||
|
bucket["material"] += n.material_cost or 0
|
||||||
|
bucket["service"] += n.service_cost or 0
|
||||||
|
bucket["other"] += n.other_cost or 0
|
||||||
|
total_cost += n.total_cost or 0
|
||||||
|
else:
|
||||||
|
open_count += 1
|
||||||
|
days = max(0, (now - n.stage_entered_at).days)
|
||||||
|
for lo, hi, label in _AGING_BUCKETS:
|
||||||
|
if days >= lo and (hi is None or days <= hi):
|
||||||
|
aging_counts[label] += 1
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── cycle times from the transition history ─────────────────────────────
|
||||||
|
ncr_ids = [n.id for n in ncrs]
|
||||||
|
stage_durations: dict[str, list[float]] = defaultdict(list)
|
||||||
|
end_to_end: list[float] = []
|
||||||
|
if ncr_ids:
|
||||||
|
transitions = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(StageTransition)
|
||||||
|
.where(StageTransition.ncr_id.in_(ncr_ids))
|
||||||
|
.order_by(StageTransition.ncr_id, StageTransition.acted_at)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
per_ncr: dict[int, list[StageTransition]] = defaultdict(list)
|
||||||
|
for t in transitions:
|
||||||
|
per_ncr[t.ncr_id].append(t)
|
||||||
|
for items in per_ncr.values():
|
||||||
|
for prev, nxt in zip(items, items[1:]):
|
||||||
|
delta_days = (nxt.acted_at - prev.acted_at).total_seconds() / 86400
|
||||||
|
stage_durations[prev.to_stage].append(delta_days)
|
||||||
|
first, last = items[0], items[-1]
|
||||||
|
if last.to_stage == Stage.CLOSED.value:
|
||||||
|
end_to_end.append(
|
||||||
|
(last.acted_at - first.acted_at).total_seconds() / 86400
|
||||||
|
)
|
||||||
|
|
||||||
|
cycle_times = [
|
||||||
|
StageCycleTime(
|
||||||
|
stage=s.value,
|
||||||
|
stage_label=STAGE_LABELS[s],
|
||||||
|
avg_days=round(sum(v) / len(v), 2),
|
||||||
|
samples=len(v),
|
||||||
|
)
|
||||||
|
for s in Stage
|
||||||
|
if s != Stage.CLOSED and (v := stage_durations.get(s.value))
|
||||||
|
]
|
||||||
|
|
||||||
|
months = sorted(set(by_month) | set(cost_by_month))
|
||||||
|
return ReportsSummaryOut(
|
||||||
|
total_ncrs=len(ncrs),
|
||||||
|
open_ncrs=open_count,
|
||||||
|
closed_ncrs=closed_count,
|
||||||
|
total_cost=total_cost,
|
||||||
|
by_department=sorted(
|
||||||
|
(CountByName(name=k, count=v) for k, v in by_dept.items()),
|
||||||
|
key=lambda x: -x.count,
|
||||||
|
),
|
||||||
|
by_category=sorted(
|
||||||
|
(CountByName(name=k, count=v) for k, v in by_cat.items()),
|
||||||
|
key=lambda x: -x.count,
|
||||||
|
),
|
||||||
|
by_month=[CountByMonth(month=m, count=by_month.get(m, 0)) for m in months],
|
||||||
|
cost_over_time=[
|
||||||
|
CostByMonth(
|
||||||
|
month=m,
|
||||||
|
labor=c["labor"],
|
||||||
|
material=c["material"],
|
||||||
|
service=c["service"],
|
||||||
|
other=c["other"],
|
||||||
|
total=c["labor"] + c["material"] + c["service"] + c["other"],
|
||||||
|
)
|
||||||
|
for m in months
|
||||||
|
if (c := cost_by_month.get(m))
|
||||||
|
],
|
||||||
|
aging=[AgingBucket(bucket=label, count=aging_counts[label]) for _, _, label in _AGING_BUCKETS],
|
||||||
|
cycle_times=cycle_times,
|
||||||
|
end_to_end_avg_days=(
|
||||||
|
round(sum(end_to_end) / len(end_to_end), 2) if end_to_end else None
|
||||||
|
),
|
||||||
|
top_jobs=sorted(
|
||||||
|
(TopJob(job_number=j, count=c) for j, c in job_counts.items()),
|
||||||
|
key=lambda x: -x.count,
|
||||||
|
)[:10],
|
||||||
|
)
|
||||||
55
backend/app/routers/users.py
Normal file
55
backend/app/routers/users.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.deps import CurrentUser, get_current_user
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.domain import ALL_ROLES
|
||||||
|
from app.models import User, UserRole
|
||||||
|
from app.schemas.user import MeOut, UserOut
|
||||||
|
|
||||||
|
router = APIRouter(tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=MeOut)
|
||||||
|
async def get_me(current: CurrentUser = Depends(get_current_user)) -> MeOut:
|
||||||
|
u = current.user
|
||||||
|
return MeOut(
|
||||||
|
id=u.id,
|
||||||
|
display_name=u.display_name,
|
||||||
|
email=u.email,
|
||||||
|
employee_id=u.employee_id,
|
||||||
|
is_active=u.is_active,
|
||||||
|
roles=sorted(current.roles),
|
||||||
|
last_login_at=u.last_login_at,
|
||||||
|
auth_mode=get_settings().auth_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=list[UserOut])
|
||||||
|
async def list_users(
|
||||||
|
role: str | None = Query(default=None, description="Filter to users holding this role"),
|
||||||
|
_: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[UserOut]:
|
||||||
|
"""User directory for pickers (e.g. Disposition Authority dropdown,
|
||||||
|
'Notify These People'). Only active users are returned."""
|
||||||
|
stmt = select(User).where(User.is_active.is_(True)).order_by(User.display_name)
|
||||||
|
if role:
|
||||||
|
if role not in ALL_ROLES:
|
||||||
|
return []
|
||||||
|
stmt = stmt.join(UserRole, UserRole.user_id == User.id).where(UserRole.role == role)
|
||||||
|
users = (await db.execute(stmt)).scalars().unique().all()
|
||||||
|
return [
|
||||||
|
UserOut(
|
||||||
|
id=u.id,
|
||||||
|
display_name=u.display_name,
|
||||||
|
email=u.email,
|
||||||
|
employee_id=u.employee_id,
|
||||||
|
is_active=u.is_active,
|
||||||
|
roles=u.roles,
|
||||||
|
last_login_at=u.last_login_at,
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
19
backend/app/schemas/common.py
Normal file
19
backend/app/schemas/common.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, PlainSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_utc(dt: datetime) -> str:
|
||||||
|
"""All DB datetimes are naive UTC; emit RFC3339 with Z so browsers parse
|
||||||
|
them into the user's local timezone."""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
UTCDateTime = Annotated[datetime, PlainSerializer(_serialize_utc, return_type=str)]
|
||||||
|
|
||||||
|
|
||||||
|
class AppModel(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
23
backend/app/schemas/lookup.py
Normal file
23
backend/app/schemas/lookup.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.common import AppModel
|
||||||
|
|
||||||
|
|
||||||
|
class NamedLookupOut(AppModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class LookupCreateIn(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class LookupPatchIn(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LookupsOut(BaseModel):
|
||||||
|
departments: list[NamedLookupOut]
|
||||||
|
deviation_categories: list[NamedLookupOut]
|
||||||
187
backend/app/schemas/ncr.py
Normal file
187
backend/app/schemas/ncr.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from app.domain import REOPEN_TARGET_STAGES, Stage
|
||||||
|
from app.schemas.common import AppModel, UTCDateTime
|
||||||
|
from app.schemas.user import UserRef
|
||||||
|
|
||||||
|
Money = Annotated[Decimal, Field(ge=0, max_digits=12, decimal_places=2)]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Inputs ───────────────────────────────────────────────────────────────────
|
||||||
|
class NcrCreateIn(BaseModel):
|
||||||
|
job_number: str = Field(min_length=1, max_length=100)
|
||||||
|
department_id: int
|
||||||
|
deviation_category_id: int
|
||||||
|
disposition_authority_id: int
|
||||||
|
deviation_detail: str = Field(min_length=5, max_length=20000)
|
||||||
|
|
||||||
|
|
||||||
|
class InitialDispositionIn(BaseModel):
|
||||||
|
qc_authority: str | None = Field(default=None, max_length=255)
|
||||||
|
work_order: str | None = Field(default=None, max_length=100)
|
||||||
|
disposition_notes: str | None = Field(default=None, max_length=100000)
|
||||||
|
secondary_review_needed: bool
|
||||||
|
# "Notify These People" — required when secondary_review_needed is true.
|
||||||
|
secondary_authority_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SecondaryDispositionIn(BaseModel):
|
||||||
|
qc_authority: str | None = Field(default=None, max_length=255)
|
||||||
|
work_order: str | None = Field(default=None, max_length=100)
|
||||||
|
disposition_notes: str | None = Field(default=None, max_length=100000)
|
||||||
|
# False = save updates and keep in my queue; True = release to Operations.
|
||||||
|
release: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class InspectionIn(BaseModel):
|
||||||
|
qc_approval: Literal["yes", "no"] | None = None
|
||||||
|
inspection_notes: str | None = Field(default=None, max_length=20000)
|
||||||
|
# False = save and revisit later; True = advance to Costing.
|
||||||
|
qc_closed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CostingIn(BaseModel):
|
||||||
|
labor_cost: Money
|
||||||
|
material_cost: Money
|
||||||
|
service_cost: Money
|
||||||
|
other_cost: Money
|
||||||
|
|
||||||
|
|
||||||
|
class ReopenIn(BaseModel):
|
||||||
|
to_stage: Stage
|
||||||
|
reason: str = Field(min_length=5, max_length=2000)
|
||||||
|
|
||||||
|
@field_validator("to_stage")
|
||||||
|
@classmethod
|
||||||
|
def _valid_target(cls, v: Stage) -> Stage:
|
||||||
|
if v not in REOPEN_TARGET_STAGES:
|
||||||
|
raise ValueError("Reopen target must be a prior (non-closed) stage.")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# ── Outputs ──────────────────────────────────────────────────────────────────
|
||||||
|
class AttachmentOut(AppModel):
|
||||||
|
id: int
|
||||||
|
original_filename: str
|
||||||
|
content_type: str
|
||||||
|
size_bytes: int
|
||||||
|
is_image: bool
|
||||||
|
uploaded_at: UTCDateTime
|
||||||
|
uploaded_by: UserRef
|
||||||
|
|
||||||
|
|
||||||
|
class TransitionOut(AppModel):
|
||||||
|
id: int
|
||||||
|
from_stage: str | None
|
||||||
|
to_stage: str
|
||||||
|
action: str
|
||||||
|
acted_at: UTCDateTime
|
||||||
|
acted_by: UserRef
|
||||||
|
note: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class JobInfoOut(AppModel):
|
||||||
|
part_id: str | None
|
||||||
|
part_description: str | None
|
||||||
|
customer_name: str | None
|
||||||
|
work_order_status: str | None
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
class NcrListItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
ncr_number: str
|
||||||
|
job_number: str
|
||||||
|
department: str
|
||||||
|
deviation_category: str
|
||||||
|
requester: str
|
||||||
|
disposition_authority: str
|
||||||
|
stage: str
|
||||||
|
stage_label: str
|
||||||
|
days_in_stage: int
|
||||||
|
created_at: UTCDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class NcrListOut(BaseModel):
|
||||||
|
items: list[NcrListItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class NcrDetailOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
ncr_number: str
|
||||||
|
job_number: str
|
||||||
|
created_at: UTCDateTime
|
||||||
|
stage: str
|
||||||
|
stage_label: str
|
||||||
|
stage_entered_at: UTCDateTime
|
||||||
|
days_in_stage: int
|
||||||
|
|
||||||
|
department: str
|
||||||
|
department_id: int
|
||||||
|
deviation_category: str
|
||||||
|
deviation_category_id: int
|
||||||
|
deviation_detail: str
|
||||||
|
requester: UserRef
|
||||||
|
disposition_authority: UserRef
|
||||||
|
|
||||||
|
qc_authority: str | None
|
||||||
|
work_order: str | None
|
||||||
|
disposition_notes: str | None
|
||||||
|
secondary_review_needed: bool | None
|
||||||
|
secondary_authorities: list[UserRef]
|
||||||
|
|
||||||
|
operations_complete: bool
|
||||||
|
operations_completed_at: UTCDateTime | None
|
||||||
|
operations_completed_by: UserRef | None
|
||||||
|
|
||||||
|
qc_approval: str | None
|
||||||
|
inspection_notes: str | None
|
||||||
|
qc_closed: bool
|
||||||
|
qc_closed_at: UTCDateTime | None
|
||||||
|
qc_closed_by: UserRef | None
|
||||||
|
|
||||||
|
labor_cost: Decimal | None
|
||||||
|
material_cost: Decimal | None
|
||||||
|
service_cost: Decimal | None
|
||||||
|
other_cost: Decimal | None
|
||||||
|
total_cost: Decimal | None
|
||||||
|
costing_completed_at: UTCDateTime | None
|
||||||
|
costing_completed_by: UserRef | None
|
||||||
|
|
||||||
|
closed_at: UTCDateTime | None
|
||||||
|
closed_by: UserRef | None
|
||||||
|
|
||||||
|
job_info: JobInfoOut | None
|
||||||
|
attachments: list[AttachmentOut]
|
||||||
|
transitions: list[TransitionOut]
|
||||||
|
|
||||||
|
# Actions the *current* user may take right now (informs the UI; the API
|
||||||
|
# re-enforces every one of these server-side).
|
||||||
|
available_actions: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class NcrMutationOut(BaseModel):
|
||||||
|
ncr: NcrDetailOut
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEntryOut(AppModel):
|
||||||
|
id: int
|
||||||
|
created_at: UTCDateTime
|
||||||
|
user: UserRef
|
||||||
|
action: str
|
||||||
|
field_name: str | None
|
||||||
|
old_value: str | None
|
||||||
|
new_value: str | None
|
||||||
|
detail: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditListOut(BaseModel):
|
||||||
|
items: list[AuditEntryOut]
|
||||||
|
total: int
|
||||||
54
backend/app/schemas/report.py
Normal file
54
backend/app/schemas/report.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CountByName(BaseModel):
|
||||||
|
name: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class CountByMonth(BaseModel):
|
||||||
|
month: str # YYYY-MM
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class CostByMonth(BaseModel):
|
||||||
|
month: str
|
||||||
|
labor: Decimal
|
||||||
|
material: Decimal
|
||||||
|
service: Decimal
|
||||||
|
other: Decimal
|
||||||
|
total: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class AgingBucket(BaseModel):
|
||||||
|
bucket: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class StageCycleTime(BaseModel):
|
||||||
|
stage: str
|
||||||
|
stage_label: str
|
||||||
|
avg_days: float
|
||||||
|
samples: int
|
||||||
|
|
||||||
|
|
||||||
|
class TopJob(BaseModel):
|
||||||
|
job_number: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ReportsSummaryOut(BaseModel):
|
||||||
|
total_ncrs: int
|
||||||
|
open_ncrs: int
|
||||||
|
closed_ncrs: int
|
||||||
|
total_cost: Decimal
|
||||||
|
by_department: list[CountByName]
|
||||||
|
by_category: list[CountByName]
|
||||||
|
by_month: list[CountByMonth]
|
||||||
|
cost_over_time: list[CostByMonth]
|
||||||
|
aging: list[AgingBucket]
|
||||||
|
cycle_times: list[StageCycleTime]
|
||||||
|
end_to_end_avg_days: float | None
|
||||||
|
top_jobs: list[TopJob]
|
||||||
33
backend/app/schemas/user.py
Normal file
33
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
from app.domain import ALL_ROLES
|
||||||
|
from app.schemas.common import AppModel, UTCDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class UserRef(AppModel):
|
||||||
|
id: int
|
||||||
|
display_name: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(UserRef):
|
||||||
|
employee_id: str | None = None
|
||||||
|
is_active: bool
|
||||||
|
roles: list[str]
|
||||||
|
last_login_at: UTCDateTime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MeOut(UserOut):
|
||||||
|
auth_mode: str = "entra"
|
||||||
|
|
||||||
|
|
||||||
|
class RolesUpdateIn(BaseModel):
|
||||||
|
roles: list[str]
|
||||||
|
|
||||||
|
@field_validator("roles")
|
||||||
|
@classmethod
|
||||||
|
def _valid_roles(cls, v: list[str]) -> list[str]:
|
||||||
|
unknown = set(v) - ALL_ROLES
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"Unknown roles: {', '.join(sorted(unknown))}")
|
||||||
|
return sorted(set(v))
|
||||||
243
backend/app/seed.py
Normal file
243
backend/app/seed.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"""Idempotent seed script.
|
||||||
|
|
||||||
|
docker compose exec api python -m app.seed
|
||||||
|
|
||||||
|
Always ensures the default departments/deviation categories and the
|
||||||
|
notifications setting. When SEED_DEMO_DATA=true it also creates dev users
|
||||||
|
(one per role — usable directly with AUTH_MODE=dev) and a spread of sample
|
||||||
|
NCRs across every workflow stage for development and demos.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_session_factory
|
||||||
|
from app.domain import Role, Stage
|
||||||
|
from app.models import (
|
||||||
|
AppSetting,
|
||||||
|
Department,
|
||||||
|
DeviationCategory,
|
||||||
|
Ncr,
|
||||||
|
NcrSecondaryAssignee,
|
||||||
|
StageTransition,
|
||||||
|
User,
|
||||||
|
UserRole,
|
||||||
|
)
|
||||||
|
from app.models.app_setting import NOTIFICATIONS_ENABLED_KEY
|
||||||
|
from app.models.base import utcnow
|
||||||
|
from app.services.numbering import allocate_ncr_number
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
log = logging.getLogger("seed")
|
||||||
|
|
||||||
|
DEPARTMENTS = [
|
||||||
|
"Machining", "Welding", "Fabrication", "Assembly", "Paint & Coating",
|
||||||
|
"Shipping / Receiving", "Engineering", "Quality",
|
||||||
|
]
|
||||||
|
|
||||||
|
CATEGORIES = [
|
||||||
|
"Dimensional", "Material Defect", "Weld Defect", "Documentation",
|
||||||
|
"Process Deviation", "Supplier Nonconformance", "Damage / Handling", "Other",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEV_USERS = [
|
||||||
|
("admin@pescoinc.biz", "Dev Admin", list(r.value for r in Role)),
|
||||||
|
("dispo@pescoinc.biz", "Dana Disposition", [Role.REQUESTER.value, Role.DISPOSITION_AUTHORITY.value]),
|
||||||
|
("second@pescoinc.biz", "Sam Secondary", [Role.REQUESTER.value, Role.SECONDARY_DISPOSITION_AUTHORITY.value]),
|
||||||
|
("ops@pescoinc.biz", "Owen Operations", [Role.REQUESTER.value, Role.OPERATIONS.value]),
|
||||||
|
("qc@pescoinc.biz", "Quinn Inspector", [Role.REQUESTER.value, Role.QC_INSPECTOR.value]),
|
||||||
|
("cost@pescoinc.biz", "Casey Costing", [Role.REQUESTER.value, Role.COSTING.value]),
|
||||||
|
("req@pescoinc.biz", "Riley Requester", [Role.REQUESTER.value]),
|
||||||
|
]
|
||||||
|
|
||||||
|
DETAILS = [
|
||||||
|
"Bore diameter measured 0.008\" over drawing tolerance on 3 of 12 pieces.",
|
||||||
|
"Weld porosity found on the underside seam during visual inspection.",
|
||||||
|
"Wrong material grade pulled from stock; heat number does not match the traveler.",
|
||||||
|
"Paint runs and inadequate coverage on exterior panels after first coat.",
|
||||||
|
"Fixture shifted during machining; datum surfaces out of parallel by 0.015\".",
|
||||||
|
"Supplier-provided casting shows shrinkage cavity at the flange face.",
|
||||||
|
"Part dropped during transfer between stations; visible dent on sealing surface.",
|
||||||
|
"Traveler missing signed inspection step for operation 40.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
async with session_factory() as db:
|
||||||
|
# ── lookups ──────────────────────────────────────────────────────────
|
||||||
|
existing = {
|
||||||
|
d.name for d in (await db.execute(select(Department))).scalars().all()
|
||||||
|
}
|
||||||
|
for name in DEPARTMENTS:
|
||||||
|
if name not in existing:
|
||||||
|
db.add(Department(name=name, is_active=True))
|
||||||
|
existing = {
|
||||||
|
c.name
|
||||||
|
for c in (await db.execute(select(DeviationCategory))).scalars().all()
|
||||||
|
}
|
||||||
|
for name in CATEGORIES:
|
||||||
|
if name not in existing:
|
||||||
|
db.add(DeviationCategory(name=name, is_active=True))
|
||||||
|
|
||||||
|
if await db.get(AppSetting, NOTIFICATIONS_ENABLED_KEY) is None:
|
||||||
|
db.add(
|
||||||
|
AppSetting(
|
||||||
|
key=NOTIFICATIONS_ENABLED_KEY,
|
||||||
|
value="true" if settings.notifications_enabled_default else "false",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
log.info("Lookups + settings seeded.")
|
||||||
|
|
||||||
|
if not settings.seed_demo_data:
|
||||||
|
log.info("SEED_DEMO_DATA is false — skipping demo users/NCRs. Done.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── dev users ────────────────────────────────────────────────────────
|
||||||
|
users: dict[str, User] = {}
|
||||||
|
for email, name, roles in DEV_USERS:
|
||||||
|
user = (
|
||||||
|
await db.execute(select(User).where(User.email == email))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
user = User(email=email, display_name=name, is_active=True)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
for role in roles:
|
||||||
|
db.add(UserRole(user_id=user.id, role=role))
|
||||||
|
users[email] = user
|
||||||
|
await db.commit()
|
||||||
|
log.info("Dev users seeded: %s", ", ".join(u for u, _, _ in DEV_USERS))
|
||||||
|
|
||||||
|
# ── demo NCRs ────────────────────────────────────────────────────────
|
||||||
|
ncr_count = (await db.execute(select(Ncr.id).limit(1))).first()
|
||||||
|
if ncr_count is not None:
|
||||||
|
log.info("NCRs already exist — skipping demo NCR creation. Done.")
|
||||||
|
return
|
||||||
|
|
||||||
|
departments = (await db.execute(select(Department))).scalars().all()
|
||||||
|
categories = (await db.execute(select(DeviationCategory))).scalars().all()
|
||||||
|
rng = random.Random(42)
|
||||||
|
|
||||||
|
dispo = users["dispo@pescoinc.biz"]
|
||||||
|
second = users["second@pescoinc.biz"]
|
||||||
|
ops = users["ops@pescoinc.biz"]
|
||||||
|
qc = users["qc@pescoinc.biz"]
|
||||||
|
cost = users["cost@pescoinc.biz"]
|
||||||
|
req = users["req@pescoinc.biz"]
|
||||||
|
|
||||||
|
# (target_stage, count)
|
||||||
|
plan = [
|
||||||
|
(Stage.NEW_REQUEST, 3),
|
||||||
|
(Stage.SECONDARY_DISPOSITION, 2),
|
||||||
|
(Stage.OPERATIONS, 3),
|
||||||
|
(Stage.QC_INSPECTION, 2),
|
||||||
|
(Stage.COSTING, 2),
|
||||||
|
(Stage.CLOSED, 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
for target, count in plan:
|
||||||
|
for _ in range(count):
|
||||||
|
days_ago = rng.randint(5, 120)
|
||||||
|
created = utcnow() - timedelta(days=days_ago)
|
||||||
|
number, year, seq = await allocate_ncr_number(db, now=created)
|
||||||
|
use_secondary = rng.random() < 0.4 or target == Stage.SECONDARY_DISPOSITION
|
||||||
|
ncr = Ncr(
|
||||||
|
ncr_number=number,
|
||||||
|
ncr_year=year,
|
||||||
|
ncr_seq=seq,
|
||||||
|
job_number=f"J{rng.randint(10000, 49999)}",
|
||||||
|
department_id=rng.choice(departments).id,
|
||||||
|
deviation_category_id=rng.choice(categories).id,
|
||||||
|
disposition_authority_id=dispo.id,
|
||||||
|
deviation_detail=rng.choice(DETAILS),
|
||||||
|
requester_id=req.id,
|
||||||
|
stage=Stage.NEW_REQUEST.value,
|
||||||
|
created_at=created,
|
||||||
|
stage_entered_at=created,
|
||||||
|
updated_at=created,
|
||||||
|
)
|
||||||
|
db.add(ncr)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
t = created
|
||||||
|
db.add(StageTransition(
|
||||||
|
ncr_id=ncr.id, from_stage=None, to_stage=Stage.NEW_REQUEST.value,
|
||||||
|
action="create", acted_by_id=req.id, acted_at=t,
|
||||||
|
))
|
||||||
|
|
||||||
|
def hop(days_lo=1, days_hi=4):
|
||||||
|
nonlocal t
|
||||||
|
t = min(utcnow(), t + timedelta(days=rng.randint(days_lo, days_hi),
|
||||||
|
hours=rng.randint(0, 8)))
|
||||||
|
return t
|
||||||
|
|
||||||
|
def advance(to_stage: Stage, action: str, actor: User, note=None):
|
||||||
|
db.add(StageTransition(
|
||||||
|
ncr_id=ncr.id, from_stage=ncr.stage, to_stage=to_stage.value,
|
||||||
|
action=action, acted_by_id=actor.id, acted_at=hop(), note=note,
|
||||||
|
))
|
||||||
|
ncr.stage = to_stage.value
|
||||||
|
ncr.stage_entered_at = t
|
||||||
|
|
||||||
|
if target == Stage.NEW_REQUEST:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# initial disposition
|
||||||
|
ncr.qc_authority = "AS9100 8.7"
|
||||||
|
ncr.work_order = f"WO-{rng.randint(1000, 9999)}"
|
||||||
|
ncr.disposition_notes = (
|
||||||
|
"<p><strong>Disposition:</strong> Rework per attached instructions. "
|
||||||
|
"Re-inspect all affected features.</p>"
|
||||||
|
)
|
||||||
|
ncr.secondary_review_needed = use_secondary
|
||||||
|
if use_secondary:
|
||||||
|
db.add(NcrSecondaryAssignee(ncr_id=ncr.id, user_id=second.id))
|
||||||
|
advance(Stage.SECONDARY_DISPOSITION, "initial_disposition", dispo)
|
||||||
|
if target == Stage.SECONDARY_DISPOSITION:
|
||||||
|
continue
|
||||||
|
advance(Stage.OPERATIONS, "secondary_release", second)
|
||||||
|
else:
|
||||||
|
advance(Stage.OPERATIONS, "initial_disposition", dispo)
|
||||||
|
if target == Stage.OPERATIONS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ncr.operations_complete = True
|
||||||
|
ncr.operations_completed_by_id = ops.id
|
||||||
|
advance(Stage.QC_INSPECTION, "operations_complete", ops)
|
||||||
|
ncr.operations_completed_at = t
|
||||||
|
if target == Stage.QC_INSPECTION:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ncr.qc_approval = "yes"
|
||||||
|
ncr.inspection_notes = "Reworked features re-inspected; all within tolerance."
|
||||||
|
ncr.qc_closed = True
|
||||||
|
ncr.qc_closed_by_id = qc.id
|
||||||
|
advance(Stage.COSTING, "qc_close", qc)
|
||||||
|
ncr.qc_closed_at = t
|
||||||
|
if target == Stage.COSTING:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ncr.labor_cost = Decimal(rng.randint(80, 2400))
|
||||||
|
ncr.material_cost = Decimal(rng.randint(0, 1800))
|
||||||
|
ncr.service_cost = Decimal(rng.choice([0, 0, 150, 450, 900]))
|
||||||
|
ncr.other_cost = Decimal(rng.choice([0, 0, 0, 75, 200]))
|
||||||
|
ncr.costing_completed_by_id = cost.id
|
||||||
|
ncr.closed_by_id = cost.id
|
||||||
|
advance(Stage.CLOSED, "complete_costing", cost)
|
||||||
|
ncr.costing_completed_at = t
|
||||||
|
ncr.closed_at = t
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
log.info("Demo NCRs seeded. Done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed())
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
67
backend/app/services/audit.py
Normal file
67
backend/app/services/audit.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Audit trail helpers. Audit rows are append-only: the application exposes
|
||||||
|
no endpoint that updates or deletes them."""
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import AuditLog, Ncr
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def audit_event(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
action: str,
|
||||||
|
ncr_id: int | None = None,
|
||||||
|
field_name: str | None = None,
|
||||||
|
old_value: Any = None,
|
||||||
|
new_value: Any = None,
|
||||||
|
detail: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
db.add(
|
||||||
|
AuditLog(
|
||||||
|
ncr_id=ncr_id,
|
||||||
|
user_id=user_id,
|
||||||
|
action=action,
|
||||||
|
field_name=field_name,
|
||||||
|
old_value=_fmt(old_value),
|
||||||
|
new_value=_fmt(new_value),
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_field_updates(
|
||||||
|
db: AsyncSession,
|
||||||
|
ncr: Ncr,
|
||||||
|
user_id: int,
|
||||||
|
updates: dict[str, Any],
|
||||||
|
action: str = "update",
|
||||||
|
) -> dict[str, tuple[Any, Any]]:
|
||||||
|
"""Set attributes on the NCR, writing one audit row per actually-changed
|
||||||
|
field. Returns {field: (old, new)} for the fields that changed."""
|
||||||
|
changes: dict[str, tuple[Any, Any]] = {}
|
||||||
|
for field, new_value in updates.items():
|
||||||
|
old_value = getattr(ncr, field)
|
||||||
|
if old_value == new_value:
|
||||||
|
continue
|
||||||
|
setattr(ncr, field, new_value)
|
||||||
|
changes[field] = (old_value, new_value)
|
||||||
|
audit_event(
|
||||||
|
db,
|
||||||
|
ncr_id=ncr.id,
|
||||||
|
user_id=user_id,
|
||||||
|
action=action,
|
||||||
|
field_name=field,
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=new_value,
|
||||||
|
)
|
||||||
|
return changes
|
||||||
91
backend/app/services/graph.py
Normal file
91
backend/app/services/graph.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Microsoft Graph helpers.
|
||||||
|
|
||||||
|
Delegated access uses the OAuth2 On-Behalf-Of (OBO) flow: the SPA sends the
|
||||||
|
API its access token (audience = this API); the API exchanges it with Entra ID
|
||||||
|
for a Graph token carrying the *signed-in user's* identity, so mail goes out
|
||||||
|
from that user's own mailbox. This keeps Graph scopes off the frontend and
|
||||||
|
needs no extra token plumbing on state-changing requests.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
||||||
|
MAIL_SEND_SCOPE = "https://graph.microsoft.com/Mail.Send"
|
||||||
|
GROUP_READ_SCOPE = "https://graph.microsoft.com/GroupMember.Read.All"
|
||||||
|
|
||||||
|
_cca = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cca():
|
||||||
|
global _cca
|
||||||
|
if _cca is None:
|
||||||
|
import msal # imported lazily so tests never need Entra config
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
_cca = msal.ConfidentialClientApplication(
|
||||||
|
settings.entra_client_id,
|
||||||
|
authority=f"https://login.microsoftonline.com/{settings.entra_tenant_id}",
|
||||||
|
client_credential=settings.entra_client_secret,
|
||||||
|
)
|
||||||
|
return _cca
|
||||||
|
|
||||||
|
|
||||||
|
def _acquire_obo_sync(user_token: str, scopes: list[str]) -> str:
|
||||||
|
result = _get_cca().acquire_token_on_behalf_of(
|
||||||
|
user_assertion=user_token, scopes=scopes
|
||||||
|
)
|
||||||
|
if "access_token" in result:
|
||||||
|
return result["access_token"]
|
||||||
|
raise RuntimeError(
|
||||||
|
f"OBO token exchange failed: {result.get('error')}: "
|
||||||
|
f"{result.get('error_description')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def acquire_obo_token(user_token: str, scopes: list[str]) -> str:
|
||||||
|
"""Exchange the caller's API access token for a delegated Graph token."""
|
||||||
|
return await anyio.to_thread.run_sync(partial(_acquire_obo_sync, user_token, scopes))
|
||||||
|
|
||||||
|
|
||||||
|
async def send_mail_as_user(
|
||||||
|
user_token: str, subject: str, html_body: str, to_emails: list[str]
|
||||||
|
) -> None:
|
||||||
|
"""Send an email from the signed-in user's mailbox (delegated Mail.Send)."""
|
||||||
|
graph_token = await acquire_obo_token(user_token, [MAIL_SEND_SCOPE])
|
||||||
|
payload = {
|
||||||
|
"message": {
|
||||||
|
"subject": subject,
|
||||||
|
"body": {"contentType": "HTML", "content": html_body},
|
||||||
|
"toRecipients": [{"emailAddress": {"address": e}} for e in to_emails],
|
||||||
|
},
|
||||||
|
"saveToSentItems": True,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{GRAPH_BASE}/me/sendMail",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {graph_token}"},
|
||||||
|
)
|
||||||
|
if resp.status_code != 202:
|
||||||
|
raise RuntimeError(f"Graph sendMail returned {resp.status_code}: {resp.text[:300]}")
|
||||||
|
|
||||||
|
|
||||||
|
async def check_member_group(user_token: str, group_id: str) -> bool:
|
||||||
|
"""Group-overage fallback: ask Graph whether the signed-in user is in the
|
||||||
|
gate group. Requires delegated GroupMember.Read.All (see README)."""
|
||||||
|
graph_token = await acquire_obo_token(user_token, [GROUP_READ_SCOPE])
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{GRAPH_BASE}/me/checkMemberGroups",
|
||||||
|
json={"groupIds": [group_id]},
|
||||||
|
headers={"Authorization": f"Bearer {graph_token}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return group_id in resp.json().get("value", [])
|
||||||
116
backend/app/services/job_lookup.py
Normal file
116
backend/app/services/job_lookup.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Job number lookup abstraction — the seam for the future Infor VISUAL ERP
|
||||||
|
integration.
|
||||||
|
|
||||||
|
Today, Job Number is free text: the default NullJobLookupService accepts any
|
||||||
|
value and returns no enrichment. When PESCO is ready to integrate VISUAL,
|
||||||
|
implement VisualJobLookupService below, set JOB_LOOKUP_PROVIDER=visual (plus
|
||||||
|
the VISUAL_DB_* variables) in .env, and restart — no schema or frontend
|
||||||
|
changes required:
|
||||||
|
|
||||||
|
* the NCR schema already stores the job number exactly as entered, plus a
|
||||||
|
related `job_info` row (part_id, part_description, customer_name,
|
||||||
|
work_order_status) that any provider can populate at NCR creation;
|
||||||
|
* the frontend job-number field already calls GET /api/jobs/{job_number}/lookup
|
||||||
|
as the user types and displays whatever enrichment comes back, so
|
||||||
|
validation/autocomplete light up automatically with a real provider.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JobInfoData:
|
||||||
|
part_id: str | None = None
|
||||||
|
part_description: str | None = None
|
||||||
|
customer_name: str | None = None
|
||||||
|
work_order_status: str | None = None
|
||||||
|
source: str = "null"
|
||||||
|
|
||||||
|
|
||||||
|
class JobLookupService(Protocol):
|
||||||
|
async def lookup(self, job_number: str) -> JobInfoData | None:
|
||||||
|
"""Return read-only enrichment for a job number, or None when the job
|
||||||
|
is unknown / the provider has nothing to add. Implementations must
|
||||||
|
never raise for a merely-unknown job number."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class NullJobLookupService:
|
||||||
|
"""Default provider: job numbers are accepted as-is, no enrichment."""
|
||||||
|
|
||||||
|
async def lookup(self, job_number: str) -> JobInfoData | None: # noqa: ARG002
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class VisualJobLookupService:
|
||||||
|
"""PLACEHOLDER for the future Infor VISUAL Manufacturing (SQL Server)
|
||||||
|
integration. Not implemented yet — selecting JOB_LOOKUP_PROVIDER=visual
|
||||||
|
today raises at startup with a pointer here.
|
||||||
|
|
||||||
|
Implementation notes (verified against PESCO's VISUAL 10 schema):
|
||||||
|
|
||||||
|
* Connect read-only to the VISUAL SQL Server database (VISUAL_DB_* env
|
||||||
|
vars) with a dedicated SELECT-only SQL login. Use `aioodbc` or `pymssql`.
|
||||||
|
NEVER write to VISUAL tables — hundreds of triggers maintain derived
|
||||||
|
values and direct writes bypass application validation.
|
||||||
|
|
||||||
|
* A PESCO "job number" corresponds to a work order base id (typically
|
||||||
|
with lot/split/sub qualifiers). WORK_ORDER's primary key is composite:
|
||||||
|
(TYPE, BASE_ID, LOT_ID, SPLIT_ID, SUB_ID); manufacturing work orders
|
||||||
|
have TYPE = 'W'. Parse the entered job number into BASE_ID (and LOT_ID
|
||||||
|
when the shop uses BASE/LOT notation, e.g. "12345/1") and query:
|
||||||
|
|
||||||
|
SELECT TOP 1 wo.BASE_ID, wo.LOT_ID, wo.SUB_ID, wo.PART_ID,
|
||||||
|
wo.STATUS, wo.DESIRED_QTY, wo.CREATE_DATE,
|
||||||
|
p.DESCRIPTION AS PART_DESCRIPTION
|
||||||
|
FROM WORK_ORDER wo
|
||||||
|
LEFT JOIN PART p ON p.ID = wo.PART_ID
|
||||||
|
WHERE wo.TYPE = 'W' AND wo.BASE_ID = :base_id
|
||||||
|
ORDER BY wo.LOT_ID, wo.SPLIT_ID, wo.SUB_ID
|
||||||
|
|
||||||
|
STATUS is a one-char code (R=released, C=closed, etc.) — map it to a
|
||||||
|
readable label for work_order_status.
|
||||||
|
|
||||||
|
* Customer enrichment goes through the demand/supply linkage:
|
||||||
|
DEMAND_SUPPLY_LINK rows with SUPPLY_TYPE='WO' and SUPPLY_BASE_ID =
|
||||||
|
wo.BASE_ID (match SUPPLY_LOT_ID/SUPPLY_SPLIT_ID/SUPPLY_SUB_ID when
|
||||||
|
present) point at customer-order demand (DEMAND_TYPE='CO',
|
||||||
|
DEMAND_BASE_ID = CUST_ORDER_LINE.CUST_ORDER_ID, DEMAND_SEQ_NO = line
|
||||||
|
no). Join CUSTOMER_ORDER -> CUSTOMER for the customer name.
|
||||||
|
|
||||||
|
* Return JobInfoData(part_id=..., part_description=...,
|
||||||
|
customer_name=..., work_order_status=..., source="visual").
|
||||||
|
Return None when no WORK_ORDER row matches. Wrap connection errors in
|
||||||
|
logging + return None so an ERP outage never blocks NCR entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
raise NotImplementedError(
|
||||||
|
"VisualJobLookupService is a documented stub. Implement it per the "
|
||||||
|
"notes in app/services/job_lookup.py, or set JOB_LOOKUP_PROVIDER=null. "
|
||||||
|
f"(Configured VISUAL host: {settings.visual_db_host or 'unset'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def lookup(self, job_number: str) -> JobInfoData | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
_service: JobLookupService | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_lookup_service() -> JobLookupService:
|
||||||
|
global _service
|
||||||
|
if _service is None:
|
||||||
|
provider = get_settings().job_lookup_provider
|
||||||
|
if provider == "visual":
|
||||||
|
_service = VisualJobLookupService() # raises: intentionally loud
|
||||||
|
else:
|
||||||
|
_service = NullJobLookupService()
|
||||||
|
logger.info("Job lookup provider: %s", provider)
|
||||||
|
return _service
|
||||||
156
backend/app/services/notifications.py
Normal file
156
backend/app/services/notifications.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""Stage-transition email notifications via Microsoft Graph delegated
|
||||||
|
Mail.Send. Mail is sent FROM the mailbox of the user whose action triggered
|
||||||
|
the transition (OBO flow — see services/graph.py).
|
||||||
|
|
||||||
|
Fault tolerance contract: a Graph/network failure must never block a workflow
|
||||||
|
transition. Every failure path logs and returns a human-readable warning that
|
||||||
|
the API surfaces in the response `warnings` array; the transition itself has
|
||||||
|
already been committed by the caller.
|
||||||
|
"""
|
||||||
|
import html
|
||||||
|
import logging
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.deps import CurrentUser
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.domain import STAGE_LABELS, STAGE_OWNER_ROLE, Role, Stage
|
||||||
|
from app.models import AppSetting, Ncr, User, UserRole
|
||||||
|
from app.models.app_setting import NOTIFICATIONS_ENABLED_KEY
|
||||||
|
from app.services.graph import send_mail_as_user
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyEvent(str, Enum):
|
||||||
|
CREATED = "created"
|
||||||
|
SECONDARY_ASSIGNED = "secondary_assigned"
|
||||||
|
RELEASED_TO_OPERATIONS = "released_to_operations"
|
||||||
|
OPERATIONS_COMPLETE = "operations_complete"
|
||||||
|
QC_CLOSED = "qc_closed"
|
||||||
|
CLOSED = "closed"
|
||||||
|
REOPENED = "reopened"
|
||||||
|
|
||||||
|
|
||||||
|
_EVENT_SUBJECT = {
|
||||||
|
NotifyEvent.CREATED: "New NCR submitted — disposition needed",
|
||||||
|
NotifyEvent.SECONDARY_ASSIGNED: "Secondary disposition review assigned to you",
|
||||||
|
NotifyEvent.RELEASED_TO_OPERATIONS: "NCR released to Operations",
|
||||||
|
NotifyEvent.OPERATIONS_COMPLETE: "Operations complete — QC inspection needed",
|
||||||
|
NotifyEvent.QC_CLOSED: "QC closed — costing needed",
|
||||||
|
NotifyEvent.CLOSED: "Your NCR has been closed",
|
||||||
|
NotifyEvent.REOPENED: "NCR reopened by an administrator",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def notifications_enabled(db: AsyncSession) -> bool:
|
||||||
|
row = await db.get(AppSetting, NOTIFICATIONS_ENABLED_KEY)
|
||||||
|
if row is None:
|
||||||
|
return get_settings().notifications_enabled_default
|
||||||
|
return row.value == "true"
|
||||||
|
|
||||||
|
|
||||||
|
async def _role_emails(db: AsyncSession, role: Role) -> list[str]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(User.email)
|
||||||
|
.join(UserRole, UserRole.user_id == User.id)
|
||||||
|
.where(UserRole.role == role.value, User.is_active.is_(True))
|
||||||
|
)
|
||||||
|
return [r[0] for r in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def _recipients(db: AsyncSession, ncr: Ncr, event: NotifyEvent) -> list[str]:
|
||||||
|
if event == NotifyEvent.CREATED:
|
||||||
|
return [ncr.disposition_authority.email]
|
||||||
|
if event == NotifyEvent.SECONDARY_ASSIGNED:
|
||||||
|
return [u.email for u in ncr.secondary_authorities]
|
||||||
|
if event == NotifyEvent.RELEASED_TO_OPERATIONS:
|
||||||
|
return await _role_emails(db, Role.OPERATIONS)
|
||||||
|
if event == NotifyEvent.OPERATIONS_COMPLETE:
|
||||||
|
return await _role_emails(db, Role.QC_INSPECTOR)
|
||||||
|
if event == NotifyEvent.QC_CLOSED:
|
||||||
|
return await _role_emails(db, Role.COSTING)
|
||||||
|
if event == NotifyEvent.CLOSED:
|
||||||
|
return [ncr.requester.email]
|
||||||
|
if event == NotifyEvent.REOPENED:
|
||||||
|
owner_role = STAGE_OWNER_ROLE.get(Stage(ncr.stage))
|
||||||
|
emails = await _role_emails(db, owner_role) if owner_role else []
|
||||||
|
if ncr.requester.email not in emails:
|
||||||
|
emails.append(ncr.requester.email)
|
||||||
|
return emails
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _build_body(ncr: Ncr, event: NotifyEvent, summary: str) -> str:
|
||||||
|
e = html.escape
|
||||||
|
link = f"{get_settings().app_base_url}/ncrs/{ncr.id}"
|
||||||
|
rows = [
|
||||||
|
("NCR Number", ncr.ncr_number),
|
||||||
|
("Job Number", ncr.job_number),
|
||||||
|
("Department", ncr.department.name if ncr.department else ""),
|
||||||
|
("Deviation Category", ncr.deviation_category.name if ncr.deviation_category else ""),
|
||||||
|
("Current Stage", STAGE_LABELS.get(Stage(ncr.stage), ncr.stage)),
|
||||||
|
("Requester", ncr.requester.display_name if ncr.requester else ""),
|
||||||
|
]
|
||||||
|
table = "".join(
|
||||||
|
f"<tr><td style='padding:4px 12px 4px 0;color:#555'>{e(k)}</td>"
|
||||||
|
f"<td style='padding:4px 0'><strong>{e(v or '')}</strong></td></tr>"
|
||||||
|
for k, v in rows
|
||||||
|
)
|
||||||
|
return f"""
|
||||||
|
<div style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#222">
|
||||||
|
<h2 style="margin:0 0 4px">{e(_EVENT_SUBJECT[event])}</h2>
|
||||||
|
<p style="margin:4px 0 12px">{e(summary)}</p>
|
||||||
|
<table style="border-collapse:collapse">{table}</table>
|
||||||
|
<p style="margin:16px 0">
|
||||||
|
<a href="{e(link)}" style="background:#1a5fb4;color:#fff;padding:10px 18px;
|
||||||
|
border-radius:4px;text-decoration:none">Open {e(ncr.ncr_number)}</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888;font-size:12px">Sent automatically by the PESCO NCR system.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def send_stage_notification(
|
||||||
|
db: AsyncSession,
|
||||||
|
ncr: Ncr,
|
||||||
|
event: NotifyEvent,
|
||||||
|
actor: CurrentUser,
|
||||||
|
summary: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Best-effort notification. Returns a list of non-blocking warnings
|
||||||
|
(empty on success or when notifications are disabled)."""
|
||||||
|
try:
|
||||||
|
if not await notifications_enabled(db):
|
||||||
|
logger.info("Notifications disabled; skipping %s for %s", event, ncr.ncr_number)
|
||||||
|
return []
|
||||||
|
recipients = sorted(set(await _recipients(db, ncr, event)))
|
||||||
|
if not recipients:
|
||||||
|
logger.info("No recipients for %s on %s", event, ncr.ncr_number)
|
||||||
|
return []
|
||||||
|
if actor.token is None:
|
||||||
|
# dev auth mode: no real user token to send on behalf of
|
||||||
|
logger.info(
|
||||||
|
"[dev] Would send '%s' for %s from %s to %s",
|
||||||
|
event.value, ncr.ncr_number, actor.user.email, recipients,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
f"Email not sent (dev auth mode): '{_EVENT_SUBJECT[event]}' "
|
||||||
|
f"to {', '.join(recipients)}."
|
||||||
|
]
|
||||||
|
subject = f"[{ncr.ncr_number}] {_EVENT_SUBJECT[event]}"
|
||||||
|
body = _build_body(ncr, event, summary)
|
||||||
|
await send_mail_as_user(actor.token, subject, body, recipients)
|
||||||
|
logger.info(
|
||||||
|
"Sent %s notification for %s from %s to %s",
|
||||||
|
event.value, ncr.ncr_number, actor.user.email, recipients,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
except Exception as exc: # noqa: BLE001 — must never block the workflow
|
||||||
|
logger.exception("Notification failed for %s (%s)", ncr.ncr_number, event.value)
|
||||||
|
return [
|
||||||
|
f"The workflow change was saved, but the notification email could not "
|
||||||
|
f"be sent: {exc}"
|
||||||
|
]
|
||||||
56
backend/app/services/numbering.py
Normal file
56
backend/app/services/numbering.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Atomic NCR number allocation.
|
||||||
|
|
||||||
|
Format: NCR-YYYY-NNNN (zero-padded, per-calendar-year sequence).
|
||||||
|
|
||||||
|
Strategy: UPDATE-first on the per-year row in ncr_sequences. The UPDATE takes
|
||||||
|
a row lock (InnoDB) / reserved write lock (SQLite) that is held until the
|
||||||
|
enclosing transaction commits, so two concurrent submissions serialize and can
|
||||||
|
never read the same sequence value. If the year row doesn't exist yet
|
||||||
|
(first NCR of a new year), it is inserted inside a SAVEPOINT; a losing racer
|
||||||
|
gets an IntegrityError, rolls back only the savepoint, and proceeds to the
|
||||||
|
UPDATE which now finds the winner's row.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import NcrSequence
|
||||||
|
from app.models.base import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
async def allocate_ncr_number(
|
||||||
|
db: AsyncSession, now: datetime | None = None
|
||||||
|
) -> tuple[str, int, int]:
|
||||||
|
"""Allocate the next NCR number inside the caller's transaction.
|
||||||
|
|
||||||
|
Returns (ncr_number, year, seq). Must be called within the same
|
||||||
|
transaction that inserts the NCR so the sequence row lock is held
|
||||||
|
until commit.
|
||||||
|
"""
|
||||||
|
year = (now or utcnow()).year
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
update(NcrSequence)
|
||||||
|
.where(NcrSequence.year == year)
|
||||||
|
.values(last_seq=NcrSequence.last_seq + 1)
|
||||||
|
)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
# First NCR of this calendar year — create the sequence row.
|
||||||
|
try:
|
||||||
|
async with db.begin_nested():
|
||||||
|
db.add(NcrSequence(year=year, last_seq=0))
|
||||||
|
await db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
pass # another request created it first; fall through to UPDATE
|
||||||
|
await db.execute(
|
||||||
|
update(NcrSequence)
|
||||||
|
.where(NcrSequence.year == year)
|
||||||
|
.values(last_seq=NcrSequence.last_seq + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
seq = (
|
||||||
|
await db.execute(select(NcrSequence.last_seq).where(NcrSequence.year == year))
|
||||||
|
).scalar_one()
|
||||||
|
return f"NCR-{year}-{seq:04d}", year, seq
|
||||||
64
backend/app/services/pdf.py
Normal file
64
backend/app/services/pdf.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Printable NCR PDF (WeasyPrint) — a clean single-document rendering of the
|
||||||
|
complete NCR for hard-copy travelers and audits.
|
||||||
|
|
||||||
|
WeasyPrint is imported lazily so environments without the Pango/Cairo system
|
||||||
|
libraries (e.g. unit tests) can still import the app.
|
||||||
|
"""
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
|
from app.domain import STAGE_LABELS, Stage
|
||||||
|
from app.models import Ncr
|
||||||
|
from app.models.base import utcnow
|
||||||
|
from app.services.storage import attachment_abs_path
|
||||||
|
|
||||||
|
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||||
|
|
||||||
|
_env = Environment(
|
||||||
|
loader=FileSystemLoader(_TEMPLATES_DIR),
|
||||||
|
autoescape=select_autoescape(["html"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_html(ncr: Ncr) -> str:
|
||||||
|
images = []
|
||||||
|
other_files = []
|
||||||
|
for att in ncr.attachments:
|
||||||
|
entry = {
|
||||||
|
"filename": att.original_filename,
|
||||||
|
"uploaded_by": att.uploaded_by.display_name,
|
||||||
|
"uploaded_at": att.uploaded_at,
|
||||||
|
"size_kb": max(1, att.size_bytes // 1024),
|
||||||
|
}
|
||||||
|
path = attachment_abs_path(att.stored_path)
|
||||||
|
if att.is_image and path.is_file():
|
||||||
|
entry["src"] = path.as_uri()
|
||||||
|
images.append(entry)
|
||||||
|
else:
|
||||||
|
other_files.append(entry)
|
||||||
|
|
||||||
|
template = _env.get_template("ncr_pdf.html")
|
||||||
|
return template.render(
|
||||||
|
ncr=ncr,
|
||||||
|
stage_label=STAGE_LABELS[Stage(ncr.stage)],
|
||||||
|
stage_labels=STAGE_LABELS,
|
||||||
|
Stage=Stage,
|
||||||
|
images=images,
|
||||||
|
other_files=other_files,
|
||||||
|
generated_at=utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _html_to_pdf(html: str) -> bytes:
|
||||||
|
from weasyprint import HTML # lazy: needs Pango/Cairo system libs
|
||||||
|
|
||||||
|
return HTML(string=html).write_pdf()
|
||||||
|
|
||||||
|
|
||||||
|
async def render_ncr_pdf(ncr: Ncr) -> bytes:
|
||||||
|
html = _render_html(ncr)
|
||||||
|
# WeasyPrint rendering is CPU-bound; keep it off the event loop.
|
||||||
|
return await anyio.to_thread.run_sync(partial(_html_to_pdf, html))
|
||||||
31
backend/app/services/sanitize.py
Normal file
31
backend/app/services/sanitize.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Rich-text HTML sanitization (XSS defense) using nh3 (ammonia bindings).
|
||||||
|
Applied server-side to every rich-text field before it is stored."""
|
||||||
|
import nh3
|
||||||
|
|
||||||
|
_ALLOWED_TAGS = {
|
||||||
|
"p", "br", "div", "span",
|
||||||
|
"strong", "b", "em", "i", "u", "s", "sub", "sup",
|
||||||
|
"ul", "ol", "li",
|
||||||
|
"h1", "h2", "h3", "h4",
|
||||||
|
"blockquote", "pre", "code",
|
||||||
|
"a", "hr", "table", "thead", "tbody", "tr", "th", "td",
|
||||||
|
}
|
||||||
|
|
||||||
|
_ALLOWED_ATTRIBUTES = {
|
||||||
|
"a": {"href", "title"},
|
||||||
|
"th": {"colspan", "rowspan"},
|
||||||
|
"td": {"colspan", "rowspan"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_html(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
cleaned = nh3.clean(
|
||||||
|
value,
|
||||||
|
tags=_ALLOWED_TAGS,
|
||||||
|
attributes=_ALLOWED_ATTRIBUTES,
|
||||||
|
link_rel="noopener noreferrer",
|
||||||
|
url_schemes={"http", "https", "mailto"},
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
87
backend/app/services/storage.py
Normal file
87
backend/app/services/storage.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Attachment storage on the local filesystem (a named Docker volume in
|
||||||
|
production). Files live at ATTACHMENTS_DIR/<ncr_id>/<uuid><ext>; metadata is
|
||||||
|
kept in the attachments table."""
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = {
|
||||||
|
# images (camera capture on tablets produces jpg/png/heic)
|
||||||
|
".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".bmp", ".tiff", ".tif",
|
||||||
|
# documents
|
||||||
|
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".txt", ".msg", ".eml",
|
||||||
|
}
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = {
|
||||||
|
".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".bmp", ".tiff", ".tif",
|
||||||
|
}
|
||||||
|
|
||||||
|
CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class UploadValidationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str) -> str:
|
||||||
|
name = Path(name or "upload").name
|
||||||
|
return re.sub(r"[^\w.\- ()]", "_", name)[:255] or "upload"
|
||||||
|
|
||||||
|
|
||||||
|
async def save_attachment(upload: UploadFile, ncr_id: int) -> dict:
|
||||||
|
"""Validate and persist an uploaded file. Returns metadata for the
|
||||||
|
Attachment row. Raises UploadValidationError on type/size violations."""
|
||||||
|
settings = get_settings()
|
||||||
|
original = _safe_filename(upload.filename or "upload")
|
||||||
|
ext = Path(original).suffix.lower()
|
||||||
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
|
raise UploadValidationError(
|
||||||
|
f"File type '{ext or 'unknown'}' is not allowed. "
|
||||||
|
f"Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
stored_rel = f"{ncr_id}/{uuid.uuid4().hex}{ext}"
|
||||||
|
dest = Path(settings.attachments_dir) / stored_rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
size = 0
|
||||||
|
max_bytes = settings.max_upload_bytes
|
||||||
|
try:
|
||||||
|
with dest.open("wb") as out:
|
||||||
|
while chunk := await upload.read(CHUNK_SIZE):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > max_bytes:
|
||||||
|
raise UploadValidationError(
|
||||||
|
f"File exceeds the {settings.max_upload_mb} MB limit."
|
||||||
|
)
|
||||||
|
out.write(chunk)
|
||||||
|
except UploadValidationError:
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
if size == 0:
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
raise UploadValidationError("Uploaded file is empty.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"original_filename": original,
|
||||||
|
"stored_path": stored_rel,
|
||||||
|
"content_type": upload.content_type or "application/octet-stream",
|
||||||
|
"size_bytes": size,
|
||||||
|
"is_image": ext in IMAGE_EXTENSIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_abs_path(stored_path: str) -> Path:
|
||||||
|
settings = get_settings()
|
||||||
|
base = Path(settings.attachments_dir).resolve()
|
||||||
|
p = (base / stored_path).resolve()
|
||||||
|
if not str(p).startswith(str(base)):
|
||||||
|
raise UploadValidationError("Invalid attachment path.")
|
||||||
|
return p
|
||||||
76
backend/app/services/workflow.py
Normal file
76
backend/app/services/workflow.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Server-side workflow state machine. Every stage change flows through
|
||||||
|
`transition()`, which validates against ALLOWED_TRANSITIONS and records both
|
||||||
|
a StageTransition row (timestamps + acting user, for aging/cycle-time
|
||||||
|
reporting) and an audit entry."""
|
||||||
|
from app.domain import ALLOWED_TRANSITIONS, Stage
|
||||||
|
from app.models import Ncr, StageTransition
|
||||||
|
from app.models.base import utcnow
|
||||||
|
from app.services.audit import audit_event
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTransitionError(Exception):
|
||||||
|
def __init__(self, from_stage: str, to_stage: str):
|
||||||
|
self.from_stage = from_stage
|
||||||
|
self.to_stage = to_stage
|
||||||
|
super().__init__(f"Invalid stage transition: {from_stage} -> {to_stage}")
|
||||||
|
|
||||||
|
|
||||||
|
def transition(
|
||||||
|
db: AsyncSession,
|
||||||
|
ncr: Ncr,
|
||||||
|
to_stage: Stage,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
actor_id: int,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
from_stage = Stage(ncr.stage)
|
||||||
|
if to_stage not in ALLOWED_TRANSITIONS.get(from_stage, set()):
|
||||||
|
raise InvalidTransitionError(from_stage.value, to_stage.value)
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
ncr.stage = to_stage.value
|
||||||
|
ncr.stage_entered_at = now
|
||||||
|
db.add(
|
||||||
|
StageTransition(
|
||||||
|
ncr_id=ncr.id,
|
||||||
|
from_stage=from_stage.value,
|
||||||
|
to_stage=to_stage.value,
|
||||||
|
action=action,
|
||||||
|
acted_by_id=actor_id,
|
||||||
|
acted_at=now,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
audit_event(
|
||||||
|
db,
|
||||||
|
ncr_id=ncr.id,
|
||||||
|
user_id=actor_id,
|
||||||
|
action=action,
|
||||||
|
field_name="stage",
|
||||||
|
old_value=from_stage.value,
|
||||||
|
new_value=to_stage.value,
|
||||||
|
detail=note,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_creation(db: AsyncSession, ncr: Ncr, actor_id: int) -> None:
|
||||||
|
db.add(
|
||||||
|
StageTransition(
|
||||||
|
ncr_id=ncr.id,
|
||||||
|
from_stage=None,
|
||||||
|
to_stage=Stage.NEW_REQUEST.value,
|
||||||
|
action="create",
|
||||||
|
acted_by_id=actor_id,
|
||||||
|
acted_at=ncr.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
audit_event(
|
||||||
|
db,
|
||||||
|
ncr_id=ncr.id,
|
||||||
|
user_id=actor_id,
|
||||||
|
action="create",
|
||||||
|
detail=f"NCR {ncr.ncr_number} created",
|
||||||
|
)
|
||||||
204
backend/app/templates/ncr_pdf.html
Normal file
204
backend/app/templates/ncr_pdf.html
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: letter;
|
||||||
|
margin: 18mm 14mm 20mm 14mm;
|
||||||
|
@bottom-left { content: "{{ ncr.ncr_number }} — Non-Conformance Report"; font-size: 8pt; color: #777; }
|
||||||
|
@bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 8pt; color: #777; }
|
||||||
|
}
|
||||||
|
body { font-family: "DejaVu Sans", sans-serif; font-size: 9.5pt; color: #1a1a1a; }
|
||||||
|
h1 { font-size: 17pt; margin: 0; }
|
||||||
|
h2 {
|
||||||
|
font-size: 10.5pt; text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
background: #eef2f7; border-left: 4px solid #1a5fb4; padding: 4px 8px;
|
||||||
|
margin: 16px 0 6px;
|
||||||
|
}
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: flex-start;
|
||||||
|
border-bottom: 3px solid #1a5fb4; padding-bottom: 8px; }
|
||||||
|
.brand { font-size: 13pt; font-weight: bold; color: #1a5fb4; }
|
||||||
|
.doc-meta { text-align: right; font-size: 9pt; color: #444; }
|
||||||
|
.ncr-number { font-size: 15pt; font-weight: bold; }
|
||||||
|
.stage-chip { display: inline-block; background: #1a5fb4; color: #fff;
|
||||||
|
padding: 2px 10px; border-radius: 10px; font-size: 9pt; }
|
||||||
|
table.fields { width: 100%; border-collapse: collapse; margin: 4px 0; }
|
||||||
|
table.fields td { border: 1px solid #ccd4de; padding: 5px 7px; vertical-align: top; }
|
||||||
|
table.fields td.lbl { width: 24%; background: #f6f8fa; color: #555; font-size: 8.5pt;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.notes { border: 1px solid #ccd4de; padding: 7px; min-height: 30px; }
|
||||||
|
.pending { color: #999; font-style: italic; }
|
||||||
|
table.history { width: 100%; border-collapse: collapse; font-size: 8.5pt; }
|
||||||
|
table.history th { background: #f6f8fa; border: 1px solid #ccd4de; padding: 4px 6px;
|
||||||
|
text-align: left; }
|
||||||
|
table.history td { border: 1px solid #ccd4de; padding: 4px 6px; }
|
||||||
|
.thumb-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.thumb { width: 30%; border: 1px solid #ccd4de; padding: 4px; }
|
||||||
|
.thumb img { width: 100%; max-height: 150px; object-fit: contain; }
|
||||||
|
.thumb .cap { font-size: 7.5pt; color: #666; margin-top: 2px; }
|
||||||
|
.costs td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.costs tr.total td { font-weight: bold; background: #eef2f7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<div class="brand">PESCO</div>
|
||||||
|
<h1>Non-Conformance Report</h1>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
<div class="ncr-number">{{ ncr.ncr_number }}</div>
|
||||||
|
<div>Stage: <span class="stage-chip">{{ stage_label }}</span></div>
|
||||||
|
<div>Generated {{ generated_at.strftime("%Y-%m-%d %H:%M") }} UTC</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Request</h2>
|
||||||
|
<table class="fields">
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">NCR Number</td><td>{{ ncr.ncr_number }}</td>
|
||||||
|
<td class="lbl">Date</td><td>{{ ncr.created_at.strftime("%Y-%m-%d") }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Job Number</td><td>{{ ncr.job_number }}</td>
|
||||||
|
<td class="lbl">Department</td><td>{{ ncr.department.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Deviation Category</td><td>{{ ncr.deviation_category.name }}</td>
|
||||||
|
<td class="lbl">Requester</td><td>{{ ncr.requester.display_name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Disposition Authority</td><td>{{ ncr.disposition_authority.display_name }}</td>
|
||||||
|
<td class="lbl">Work Order</td><td>{{ ncr.work_order or "—" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if ncr.job_info %}
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Part (ERP)</td>
|
||||||
|
<td>{{ ncr.job_info.part_id or "—" }} {{ ncr.job_info.part_description or "" }}</td>
|
||||||
|
<td class="lbl">Customer (ERP)</td><td>{{ ncr.job_info.customer_name or "—" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
<div class="notes">{{ ncr.deviation_detail }}</div>
|
||||||
|
|
||||||
|
<h2>Disposition</h2>
|
||||||
|
<table class="fields">
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">QC Authority</td><td>{{ ncr.qc_authority or "—" }}</td>
|
||||||
|
<td class="lbl">Secondary Review</td>
|
||||||
|
<td>
|
||||||
|
{% if ncr.secondary_review_needed is none %}—
|
||||||
|
{% elif ncr.secondary_review_needed %}Yes —
|
||||||
|
{{ ncr.secondary_authorities | map(attribute="display_name") | join(", ") or "unassigned" }}
|
||||||
|
{% else %}No{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% if ncr.disposition_notes %}
|
||||||
|
<div class="notes">{{ ncr.disposition_notes | safe }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="notes pending">No disposition notes recorded.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Operations</h2>
|
||||||
|
<table class="fields">
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Operations Complete</td>
|
||||||
|
<td>{% if ncr.operations_complete %}Yes{% else %}<span class="pending">Pending</span>{% endif %}</td>
|
||||||
|
<td class="lbl">Completed By / At</td>
|
||||||
|
<td>
|
||||||
|
{% if ncr.operations_completed_by %}
|
||||||
|
{{ ncr.operations_completed_by.display_name }} —
|
||||||
|
{{ ncr.operations_completed_at.strftime("%Y-%m-%d %H:%M") }} UTC
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>QC Inspection</h2>
|
||||||
|
<table class="fields">
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">QC Approval</td>
|
||||||
|
<td>{{ ncr.qc_approval | capitalize if ncr.qc_approval else "—" }}</td>
|
||||||
|
<td class="lbl">QC Closed</td>
|
||||||
|
<td>
|
||||||
|
{% if ncr.qc_closed %}Yes — {{ ncr.qc_closed_by.display_name }},
|
||||||
|
{{ ncr.qc_closed_at.strftime("%Y-%m-%d %H:%M") }} UTC
|
||||||
|
{% else %}<span class="pending">Pending</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% if ncr.inspection_notes %}
|
||||||
|
<div class="notes">{{ ncr.inspection_notes }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Costing</h2>
|
||||||
|
<table class="fields costs">
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Labor</td>
|
||||||
|
<td class="num">{% if ncr.labor_cost is not none %}${{ "%.2f" | format(ncr.labor_cost) }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="lbl">Material</td>
|
||||||
|
<td class="num">{% if ncr.material_cost is not none %}${{ "%.2f" | format(ncr.material_cost) }}{% else %}—{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Service</td>
|
||||||
|
<td class="num">{% if ncr.service_cost is not none %}${{ "%.2f" | format(ncr.service_cost) }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="lbl">Other</td>
|
||||||
|
<td class="num">{% if ncr.other_cost is not none %}${{ "%.2f" | format(ncr.other_cost) }}{% else %}—{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="total">
|
||||||
|
<td class="lbl">Total Cost of Nonconformance</td>
|
||||||
|
<td class="num" colspan="3">
|
||||||
|
{% if ncr.total_cost is not none %}${{ "%.2f" | format(ncr.total_cost) }}{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% if ncr.closed_at %}
|
||||||
|
<p>Closed by {{ ncr.closed_by.display_name }} on {{ ncr.closed_at.strftime("%Y-%m-%d %H:%M") }} UTC.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if images or other_files %}
|
||||||
|
<h2>Attachments ({{ images | length + other_files | length }})</h2>
|
||||||
|
{% if images %}
|
||||||
|
<div class="thumb-grid">
|
||||||
|
{% for img in images %}
|
||||||
|
<div class="thumb">
|
||||||
|
<img src="{{ img.src }}" alt="{{ img.filename }}">
|
||||||
|
<div class="cap">{{ img.filename }} — {{ img.uploaded_by }},
|
||||||
|
{{ img.uploaded_at.strftime("%Y-%m-%d") }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if other_files %}
|
||||||
|
<table class="history" style="margin-top:6px">
|
||||||
|
<tr><th>File</th><th>Uploaded By</th><th>Date</th><th>Size</th></tr>
|
||||||
|
{% for f in other_files %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ f.filename }}</td><td>{{ f.uploaded_by }}</td>
|
||||||
|
<td>{{ f.uploaded_at.strftime("%Y-%m-%d %H:%M") }}</td><td>{{ f.size_kb }} KB</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Workflow History</h2>
|
||||||
|
<table class="history">
|
||||||
|
<tr><th>Date (UTC)</th><th>Action</th><th>From</th><th>To</th><th>By</th><th>Note</th></tr>
|
||||||
|
{% for t in ncr.transitions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ t.acted_at.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||||
|
<td>{{ t.action.replace("_", " ") | title }}</td>
|
||||||
|
<td>{{ stage_labels[Stage(t.from_stage)] if t.from_stage else "—" }}</td>
|
||||||
|
<td>{{ stage_labels[Stage(t.to_stage)] }}</td>
|
||||||
|
<td>{{ t.acted_by.display_name }}</td>
|
||||||
|
<td>{{ t.note or "" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
backend/entrypoint.sh
Normal file
17
backend/entrypoint.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[api] running database migrations..."
|
||||||
|
attempt=0
|
||||||
|
until alembic upgrade head; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
if [ "$attempt" -ge 12 ]; then
|
||||||
|
echo "[api] migrations failed after $attempt attempts, giving up." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[api] database not ready (attempt $attempt), retrying in 5s..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "[api] migrations complete."
|
||||||
|
|
||||||
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
|
||||||
6
backend/pyproject.toml
Normal file
6
backend/pyproject.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::DeprecationWarning:jose.*",
|
||||||
|
]
|
||||||
4
backend/requirements-dev.txt
Normal file
4
backend/requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.2
|
||||||
|
pytest-asyncio>=0.24
|
||||||
|
aiosqlite>=0.20
|
||||||
16
backend/requirements.txt
Normal file
16
backend/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
fastapi>=0.115,<1
|
||||||
|
uvicorn[standard]>=0.30
|
||||||
|
sqlalchemy[asyncio]>=2.0.43
|
||||||
|
alembic>=1.13
|
||||||
|
aiomysql>=0.2.3
|
||||||
|
PyMySQL>=1.1
|
||||||
|
greenlet>=3.0
|
||||||
|
pydantic>=2.9
|
||||||
|
pydantic-settings>=2.4
|
||||||
|
python-jose[cryptography]>=3.3
|
||||||
|
msal>=1.31
|
||||||
|
httpx>=0.27
|
||||||
|
nh3>=0.2.18
|
||||||
|
weasyprint>=62
|
||||||
|
jinja2>=3.1
|
||||||
|
python-multipart>=0.0.9
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
89
backend/tests/conftest.py
Normal file
89
backend/tests/conftest.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Test configuration.
|
||||||
|
|
||||||
|
The environment MUST be set before any `app.*` import (settings are cached):
|
||||||
|
tests run against a file-backed SQLite database with AUTH_MODE=dev, which
|
||||||
|
exercises the same SQLAlchemy models, state machine, numbering, and
|
||||||
|
permission code paths as MySQL. To run the suite against a real MySQL
|
||||||
|
instance instead:
|
||||||
|
|
||||||
|
DATABASE_URL="mysql+aiomysql://user:pass@host/db_test?charset=utf8mb4" pytest
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
_TMPDIR = tempfile.mkdtemp(prefix="pesco-ncr-tests-")
|
||||||
|
os.environ.setdefault("DATABASE_URL", f"sqlite+aiosqlite:///{_TMPDIR}/test.db")
|
||||||
|
os.environ["AUTH_MODE"] = "dev"
|
||||||
|
os.environ["ATTACHMENTS_DIR"] = os.path.join(_TMPDIR, "attachments")
|
||||||
|
os.environ["INITIAL_ADMIN_EMAILS"] = ""
|
||||||
|
os.environ["JOB_LOOKUP_PROVIDER"] = "null"
|
||||||
|
# Disabled by default so mutation responses have empty `warnings`;
|
||||||
|
# notification-specific tests flip the AppSetting row explicitly.
|
||||||
|
os.environ["NOTIFICATIONS_ENABLED_DEFAULT"] = "false"
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from httpx import ASGITransport, AsyncClient # noqa: E402
|
||||||
|
|
||||||
|
from app.database import get_engine, get_session_factory # noqa: E402
|
||||||
|
from app.main import app # noqa: E402
|
||||||
|
from app.models import Base, Department, DeviationCategory, User, UserRole # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def _create_schema():
|
||||||
|
async def _run():
|
||||||
|
engine = get_engine()
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with get_session_factory()() as db:
|
||||||
|
db.add(Department(name="Machining", is_active=True))
|
||||||
|
db.add(Department(name="Inactive Dept", is_active=False))
|
||||||
|
db.add(DeviationCategory(name="Dimensional", is_active=True))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_user():
|
||||||
|
async def _make(roles: list[str], name: str | None = None) -> str:
|
||||||
|
email = f"user-{uuid.uuid4().hex[:10]}@pescoinc.biz"
|
||||||
|
async with get_session_factory()() as db:
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
display_name=name or f"Test {email.split('@')[0]}",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
for role in roles:
|
||||||
|
db.add(UserRole(user_id=user.id, role=role))
|
||||||
|
await db.commit()
|
||||||
|
return email
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def team(make_user) -> dict[str, str]:
|
||||||
|
"""One user per workflow role, fresh for each test."""
|
||||||
|
return {
|
||||||
|
"requester": await make_user(["requester"]),
|
||||||
|
"dispo": await make_user(["requester", "disposition_authority"]),
|
||||||
|
"second": await make_user(["requester", "secondary_disposition_authority"]),
|
||||||
|
"second2": await make_user(["requester", "secondary_disposition_authority"]),
|
||||||
|
"ops": await make_user(["requester", "operations"]),
|
||||||
|
"qc": await make_user(["requester", "qc_inspector"]),
|
||||||
|
"cost": await make_user(["requester", "costing"]),
|
||||||
|
"admin": await make_user(["admin"]),
|
||||||
|
}
|
||||||
62
backend/tests/test_attachments.py
Normal file
62
backend/tests/test_attachments.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Attachment upload validation, metadata, download, and closure locking."""
|
||||||
|
from .util import create_ncr, hdr, to_closed
|
||||||
|
|
||||||
|
TINY_PNG = (
|
||||||
|
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||||
|
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01"
|
||||||
|
b"\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upload_download_and_metadata(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/attachments",
|
||||||
|
files=[
|
||||||
|
("files", ("photo one.png", TINY_PNG, "image/png")),
|
||||||
|
("files", ("notes.txt", b"observed at station 4", "text/plain")),
|
||||||
|
],
|
||||||
|
headers=hdr(team["requester"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
items = r.json()
|
||||||
|
assert len(items) == 2
|
||||||
|
png = next(i for i in items if i["is_image"])
|
||||||
|
assert png["original_filename"] == "photo one.png"
|
||||||
|
assert png["uploaded_by"]["email"] == team["requester"]
|
||||||
|
assert png["size_bytes"] == len(TINY_PNG)
|
||||||
|
|
||||||
|
r = await client.get(
|
||||||
|
f"/api/attachments/{png['id']}/download", headers=hdr(team["ops"])
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.content == TINY_PNG
|
||||||
|
|
||||||
|
# attachment add shows in detail + audit
|
||||||
|
detail = (await client.get(f"/api/ncrs/{ncr['id']}", headers=hdr(team["qc"]))).json()
|
||||||
|
assert len(detail["attachments"]) == 2
|
||||||
|
audit = (
|
||||||
|
await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["qc"]))
|
||||||
|
).json()
|
||||||
|
assert sum(1 for a in audit["items"] if a["action"] == "attachment_add") == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_disallowed_type_rejected(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/attachments",
|
||||||
|
files=[("files", ("malware.exe", b"MZ...", "application/octet-stream"))],
|
||||||
|
headers=hdr(team["requester"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert "not allowed" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attachments_locked_when_closed(client, team):
|
||||||
|
ncr = await to_closed(client, team, (await create_ncr(client, team))["id"])
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/attachments",
|
||||||
|
files=[("files", ("late.png", TINY_PNG, "image/png"))],
|
||||||
|
headers=hdr(team["requester"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
51
backend/tests/test_numbering.py
Normal file
51
backend/tests/test_numbering.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""NCR numbering: format, per-year sequence + rollover, and concurrency."""
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.database import get_session_factory
|
||||||
|
from app.services.numbering import allocate_ncr_number
|
||||||
|
|
||||||
|
from .util import create_ncr
|
||||||
|
|
||||||
|
NCR_RE = re.compile(r"^NCR-(\d{4})-(\d{4})$")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_number_format_and_sequence(client, team):
|
||||||
|
first = await create_ncr(client, team)
|
||||||
|
second = await create_ncr(client, team)
|
||||||
|
|
||||||
|
m1, m2 = NCR_RE.match(first["ncr_number"]), NCR_RE.match(second["ncr_number"])
|
||||||
|
assert m1 and m2, (first["ncr_number"], second["ncr_number"])
|
||||||
|
assert int(m1.group(1)) == datetime.now().year
|
||||||
|
assert int(m2.group(2)) == int(m1.group(2)) + 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_year_rollover_resets_sequence():
|
||||||
|
async with get_session_factory()() as db:
|
||||||
|
n1, year1, seq1 = await allocate_ncr_number(db, now=datetime(2098, 12, 31))
|
||||||
|
n2, year2, seq2 = await allocate_ncr_number(db, now=datetime(2099, 1, 1))
|
||||||
|
n3, _, seq3 = await allocate_ncr_number(db, now=datetime(2099, 6, 15))
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
assert (year1, seq1) == (2098, 1) and n1 == "NCR-2098-0001"
|
||||||
|
assert (year2, seq2) == (2099, 1) and n2 == "NCR-2099-0001"
|
||||||
|
assert seq3 == 2 and n3 == "NCR-2099-0002"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zero_padding():
|
||||||
|
async with get_session_factory()() as db:
|
||||||
|
number, _, _ = await allocate_ncr_number(db, now=datetime(2097, 3, 1))
|
||||||
|
await db.rollback()
|
||||||
|
assert number == "NCR-2097-0001"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_submissions_never_collide(client, team):
|
||||||
|
"""Twelve simultaneous submissions must all succeed with distinct numbers."""
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[create_ncr(client, team, job_number=f"J-CONC-{i}") for i in range(12)]
|
||||||
|
)
|
||||||
|
numbers = [r["ncr_number"] for r in results]
|
||||||
|
assert len(set(numbers)) == 12, numbers
|
||||||
|
seqs = sorted(int(NCR_RE.match(n).group(2)) for n in numbers)
|
||||||
|
assert seqs == list(range(seqs[0], seqs[0] + 12))
|
||||||
223
backend/tests/test_permissions.py
Normal file
223
backend/tests/test_permissions.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""Server-side role enforcement per stage and admin-area authorization."""
|
||||||
|
from .util import (
|
||||||
|
create_ncr,
|
||||||
|
do_initial_disposition,
|
||||||
|
hdr,
|
||||||
|
to_costing,
|
||||||
|
to_operations,
|
||||||
|
to_qc_inspection,
|
||||||
|
user_id_by_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stage_actions_require_stage_role(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
|
||||||
|
# a plain requester can't perform initial disposition
|
||||||
|
r = await do_initial_disposition(client, team, ncr["id"], as_user=team["requester"])
|
||||||
|
assert r.status_code == 403
|
||||||
|
# nor can operations/qc/costing roles
|
||||||
|
r = await do_initial_disposition(client, team, ncr["id"], as_user=team["ops"])
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
await to_operations(client, team, ncr["id"])
|
||||||
|
# only operations can mark complete
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["requester"])
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["qc"])
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["ops"])
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# only QC can edit inspection fields
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/inspection",
|
||||||
|
json={"qc_approval": "yes"},
|
||||||
|
headers=hdr(team["ops"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
# only costing can cost
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/inspection",
|
||||||
|
json={"qc_approval": "yes", "qc_closed": True},
|
||||||
|
headers=hdr(team["qc"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/costing",
|
||||||
|
json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"},
|
||||||
|
headers=hdr(team["qc"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_can_act_at_every_stage(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
r = await do_initial_disposition(client, team, ncr["id"], as_user=team["admin"])
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["admin"])
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/inspection",
|
||||||
|
json={"qc_approval": "yes", "qc_closed": True},
|
||||||
|
headers=hdr(team["admin"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/costing",
|
||||||
|
json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"},
|
||||||
|
headers=hdr(team["admin"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["ncr"]["stage"] == "closed"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_secondary_restricted_to_assignees(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
second_id = await user_id_by_email(
|
||||||
|
client, team["dispo"], team["second"], "secondary_disposition_authority"
|
||||||
|
)
|
||||||
|
await do_initial_disposition(
|
||||||
|
client, team, ncr["id"], secondary=True, secondary_ids=[second_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
# another user holding the secondary role but NOT assigned is rejected
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/secondary-disposition",
|
||||||
|
json={"release": True},
|
||||||
|
headers=hdr(team["second2"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
# the assignee is allowed
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/secondary-disposition",
|
||||||
|
json={"release": True},
|
||||||
|
headers=hdr(team["second"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["ncr"]["stage"] == "operations"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_secondary_queue_filtered_by_identity(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
second_id = await user_id_by_email(
|
||||||
|
client, team["dispo"], team["second"], "secondary_disposition_authority"
|
||||||
|
)
|
||||||
|
await do_initial_disposition(
|
||||||
|
client, team, ncr["id"], secondary=True, secondary_ids=[second_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
r = await client.get("/api/ncrs?queue=secondary", headers=hdr(team["second"]))
|
||||||
|
assert any(item["id"] == ncr["id"] for item in r.json()["items"])
|
||||||
|
|
||||||
|
r = await client.get("/api/ncrs?queue=secondary", headers=hdr(team["second2"]))
|
||||||
|
assert not any(item["id"] == ncr["id"] for item in r.json()["items"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_requires_valid_disposition_authority(client, team):
|
||||||
|
from .util import lookup_ids
|
||||||
|
|
||||||
|
dept_id, cat_id = await lookup_ids(client, team["requester"])
|
||||||
|
ops_id = await user_id_by_email(client, team["requester"], team["ops"], "operations")
|
||||||
|
r = await client.post(
|
||||||
|
"/api/ncrs",
|
||||||
|
json={
|
||||||
|
"job_number": "J1",
|
||||||
|
"department_id": dept_id,
|
||||||
|
"deviation_category_id": cat_id,
|
||||||
|
"disposition_authority_id": ops_id, # lacks the role
|
||||||
|
"deviation_detail": "Detail long enough.",
|
||||||
|
},
|
||||||
|
headers=hdr(team["requester"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_secondary_assignees_must_hold_role(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
ops_id = await user_id_by_email(client, team["dispo"], team["ops"], "operations")
|
||||||
|
r = await do_initial_disposition(
|
||||||
|
client, team, ncr["id"], secondary=True, secondary_ids=[ops_id]
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_audit_endpoint_restricted(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
r = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["requester"]))
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["qc"]))
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["admin"]))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_area_requires_admin(client, team):
|
||||||
|
for path in ("/api/admin/users", "/api/admin/departments", "/api/admin/settings",
|
||||||
|
"/api/admin/audit"):
|
||||||
|
r = await client.get(path, headers=hdr(team["requester"]))
|
||||||
|
assert r.status_code == 403, path
|
||||||
|
r = await client.get(path, headers=hdr(team["admin"]))
|
||||||
|
assert r.status_code == 200, path
|
||||||
|
|
||||||
|
|
||||||
|
async def test_role_assignment_and_lockout_protection(client, team, make_user):
|
||||||
|
target = await make_user(["requester"])
|
||||||
|
r = await client.get("/api/admin/users", headers=hdr(team["admin"]))
|
||||||
|
target_id = next(u["id"] for u in r.json() if u["email"] == target)
|
||||||
|
admin_id = next(u["id"] for u in r.json() if u["email"] == team["admin"])
|
||||||
|
|
||||||
|
r = await client.put(
|
||||||
|
f"/api/admin/users/{target_id}/roles",
|
||||||
|
json={"roles": ["requester", "qc_inspector"]},
|
||||||
|
headers=hdr(team["admin"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert set(r.json()["roles"]) == {"requester", "qc_inspector"}
|
||||||
|
|
||||||
|
# unknown role rejected
|
||||||
|
r = await client.put(
|
||||||
|
f"/api/admin/users/{target_id}/roles",
|
||||||
|
json={"roles": ["superuser"]},
|
||||||
|
headers=hdr(team["admin"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
# admin cannot remove their own admin role
|
||||||
|
r = await client.put(
|
||||||
|
f"/api/admin/users/{admin_id}/roles",
|
||||||
|
json={"roles": ["requester"]},
|
||||||
|
headers=hdr(team["admin"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_everyone_can_view_and_search(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
r = await client.get(f"/api/ncrs/{ncr['id']}", headers=hdr(team["ops"]))
|
||||||
|
assert r.status_code == 200
|
||||||
|
# available_actions reflect the viewer's role
|
||||||
|
assert "initial_disposition" not in r.json()["available_actions"]
|
||||||
|
r = await client.get(f"/api/ncrs/{ncr['id']}", headers=hdr(team["dispo"]))
|
||||||
|
assert "initial_disposition" in r.json()["available_actions"]
|
||||||
|
|
||||||
|
r = await client.get(
|
||||||
|
f"/api/ncrs?q={ncr['ncr_number']}", headers=hdr(team["requester"])
|
||||||
|
)
|
||||||
|
assert r.json()["total"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_dev_user_rejected(client):
|
||||||
|
r = await client.get("/api/me", headers=hdr("ghost@pescoinc.biz"))
|
||||||
|
assert r.status_code == 401
|
||||||
244
backend/tests/test_state_machine.py
Normal file
244
backend/tests/test_state_machine.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""Workflow state machine: happy paths, invalid transitions, closure locking,
|
||||||
|
admin reopen, and rich-text sanitization."""
|
||||||
|
from .util import (
|
||||||
|
create_ncr,
|
||||||
|
do_initial_disposition,
|
||||||
|
hdr,
|
||||||
|
to_closed,
|
||||||
|
to_costing,
|
||||||
|
to_operations,
|
||||||
|
to_qc_inspection,
|
||||||
|
user_id_by_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_lifecycle_direct_to_operations(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
assert ncr["stage"] == "new_request"
|
||||||
|
assert ncr["ncr_number"].startswith("NCR-")
|
||||||
|
|
||||||
|
ncr = await to_operations(client, team, ncr["id"])
|
||||||
|
assert ncr["stage"] == "operations"
|
||||||
|
assert ncr["secondary_review_needed"] is False
|
||||||
|
assert ncr["qc_authority"] == "AS9100 8.7"
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["ops"])
|
||||||
|
)
|
||||||
|
body = r.json()["ncr"]
|
||||||
|
assert body["stage"] == "qc_inspection"
|
||||||
|
assert body["operations_complete"] is True
|
||||||
|
assert body["operations_completed_by"]["email"] == team["ops"]
|
||||||
|
|
||||||
|
# QC can save repeatedly without closing
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/inspection",
|
||||||
|
json={"qc_approval": "no", "inspection_notes": "First pass failed."},
|
||||||
|
headers=hdr(team["qc"]),
|
||||||
|
)
|
||||||
|
assert r.json()["ncr"]["stage"] == "qc_inspection"
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/inspection",
|
||||||
|
json={"qc_approval": "yes", "inspection_notes": "Rework verified.", "qc_closed": True},
|
||||||
|
headers=hdr(team["qc"]),
|
||||||
|
)
|
||||||
|
body = r.json()["ncr"]
|
||||||
|
assert body["stage"] == "costing"
|
||||||
|
assert body["qc_closed"] is True
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/costing",
|
||||||
|
json={
|
||||||
|
"labor_cost": "100.00",
|
||||||
|
"material_cost": "50.25",
|
||||||
|
"service_cost": "0",
|
||||||
|
"other_cost": "10",
|
||||||
|
},
|
||||||
|
headers=hdr(team["cost"]),
|
||||||
|
)
|
||||||
|
body = r.json()["ncr"]
|
||||||
|
assert body["stage"] == "closed"
|
||||||
|
assert body["total_cost"] == "160.25"
|
||||||
|
assert body["closed_at"] is not None
|
||||||
|
|
||||||
|
# Transition history is complete and ordered
|
||||||
|
stages = [t["to_stage"] for t in body["transitions"]]
|
||||||
|
assert stages == ["new_request", "operations", "qc_inspection", "costing", "closed"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_secondary_disposition_flow(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
second_id = await user_id_by_email(
|
||||||
|
client, team["dispo"], team["second"], "secondary_disposition_authority"
|
||||||
|
)
|
||||||
|
|
||||||
|
# secondary review without assignees is rejected
|
||||||
|
r = await do_initial_disposition(client, team, ncr["id"], secondary=True, secondary_ids=[])
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
r = await do_initial_disposition(
|
||||||
|
client, team, ncr["id"], secondary=True, secondary_ids=[second_id]
|
||||||
|
)
|
||||||
|
body = r.json()["ncr"]
|
||||||
|
assert body["stage"] == "secondary_disposition"
|
||||||
|
assert [u["email"] for u in body["secondary_authorities"]] == [team["second"]]
|
||||||
|
|
||||||
|
# assignee saves without releasing
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/secondary-disposition",
|
||||||
|
json={"disposition_notes": "<p>Updated by secondary.</p>", "release": False},
|
||||||
|
headers=hdr(team["second"]),
|
||||||
|
)
|
||||||
|
assert r.json()["ncr"]["stage"] == "secondary_disposition"
|
||||||
|
|
||||||
|
# then releases to operations
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/secondary-disposition",
|
||||||
|
json={"work_order": "WO-2002", "release": True},
|
||||||
|
headers=hdr(team["second"]),
|
||||||
|
)
|
||||||
|
body = r.json()["ncr"]
|
||||||
|
assert body["stage"] == "operations"
|
||||||
|
assert body["work_order"] == "WO-2002"
|
||||||
|
# earlier saved notes were not wiped by the release payload
|
||||||
|
assert "Updated by secondary" in body["disposition_notes"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_transitions_rejected(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
|
||||||
|
# can't skip ahead from new_request
|
||||||
|
r = await client.post(f"/api/ncrs/{ncr['id']}/operations-complete", headers=hdr(team["ops"]))
|
||||||
|
assert r.status_code == 409
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/inspection",
|
||||||
|
json={"qc_closed": True},
|
||||||
|
headers=hdr(team["qc"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/costing",
|
||||||
|
json={"labor_cost": "1", "material_cost": "1", "service_cost": "1", "other_cost": "1"},
|
||||||
|
headers=hdr(team["cost"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
# once in operations, initial disposition can't run again
|
||||||
|
await to_operations(client, team, ncr["id"])
|
||||||
|
r = await do_initial_disposition(client, team, ncr["id"])
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_closed_ncr_is_fully_locked(client, team):
|
||||||
|
ncr = await to_closed(client, team, (await create_ncr(client, team))["id"])
|
||||||
|
assert ncr["stage"] == "closed"
|
||||||
|
|
||||||
|
for path, payload, user in [
|
||||||
|
("initial-disposition", {"secondary_review_needed": False}, team["dispo"]),
|
||||||
|
("secondary-disposition", {"release": True}, team["second"]),
|
||||||
|
("operations-complete", None, team["ops"]),
|
||||||
|
("inspection", {"qc_closed": True}, team["qc"]),
|
||||||
|
("costing", {"labor_cost": "9", "material_cost": "9", "service_cost": "9", "other_cost": "9"}, team["cost"]),
|
||||||
|
]:
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/{path}",
|
||||||
|
json=payload,
|
||||||
|
headers=hdr(user),
|
||||||
|
)
|
||||||
|
assert r.status_code == 409, f"{path}: {r.status_code} {r.text}"
|
||||||
|
assert "closed" in r.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_reopen_with_reason(client, team):
|
||||||
|
ncr = await to_closed(client, team, (await create_ncr(client, team))["id"])
|
||||||
|
|
||||||
|
# non-admin cannot reopen
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/reopen",
|
||||||
|
json={"to_stage": "costing", "reason": "Costs were entered incorrectly."},
|
||||||
|
headers=hdr(team["cost"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
# reason is required (min length)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/reopen",
|
||||||
|
json={"to_stage": "costing", "reason": ""},
|
||||||
|
headers=hdr(team["admin"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
# reopening to 'closed' is not a valid target
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/reopen",
|
||||||
|
json={"to_stage": "closed", "reason": "does not make sense"},
|
||||||
|
headers=hdr(team["admin"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/reopen",
|
||||||
|
json={"to_stage": "costing", "reason": "Costs were entered incorrectly."},
|
||||||
|
headers=hdr(team["admin"]),
|
||||||
|
)
|
||||||
|
body = r.json()["ncr"]
|
||||||
|
assert body["stage"] == "costing"
|
||||||
|
assert body["closed_at"] is None
|
||||||
|
# costs preserved for correction
|
||||||
|
assert body["labor_cost"] == "125.50"
|
||||||
|
|
||||||
|
# reopen is recorded with its reason in the transition history + audit trail
|
||||||
|
reopen_t = [t for t in body["transitions"] if t["action"] == "reopen"]
|
||||||
|
assert len(reopen_t) == 1
|
||||||
|
assert "Costs were entered incorrectly." in reopen_t[0]["note"]
|
||||||
|
|
||||||
|
audit = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["admin"]))
|
||||||
|
actions = [a["action"] for a in audit.json()["items"]]
|
||||||
|
assert "reopen" in actions
|
||||||
|
|
||||||
|
# workflow resumes: costing can close it again
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/costing",
|
||||||
|
json={"labor_cost": "200", "material_cost": "0", "service_cost": "0", "other_cost": "0"},
|
||||||
|
headers=hdr(team["cost"]),
|
||||||
|
)
|
||||||
|
assert r.json()["ncr"]["stage"] == "closed"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reopen_only_from_closed(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr['id']}/reopen",
|
||||||
|
json={"to_stage": "new_request", "reason": "not closed yet"},
|
||||||
|
headers=hdr(team["admin"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rich_text_is_sanitized(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
r = await do_initial_disposition(
|
||||||
|
client,
|
||||||
|
team,
|
||||||
|
ncr["id"],
|
||||||
|
notes='<p onclick="evil()">Keep</p><script>alert("xss")</script><a href="javascript:x()">link</a>',
|
||||||
|
)
|
||||||
|
notes = r.json()["ncr"]["disposition_notes"]
|
||||||
|
assert "<script" not in notes
|
||||||
|
assert "onclick" not in notes
|
||||||
|
assert "javascript:" not in notes
|
||||||
|
assert "Keep" in notes
|
||||||
|
|
||||||
|
|
||||||
|
async def test_audit_trail_field_level(client, team):
|
||||||
|
ncr = await create_ncr(client, team)
|
||||||
|
await to_operations(client, team, ncr["id"])
|
||||||
|
|
||||||
|
r = await client.get(f"/api/ncrs/{ncr['id']}/audit", headers=hdr(team["qc"]))
|
||||||
|
items = r.json()["items"]
|
||||||
|
by_field = {i["field_name"]: i for i in items if i["field_name"]}
|
||||||
|
assert by_field["stage"]["old_value"] == "new_request"
|
||||||
|
assert by_field["stage"]["new_value"] == "operations"
|
||||||
|
assert by_field["work_order"]["new_value"] == "WO-1001"
|
||||||
|
assert any(i["action"] == "create" for i in items)
|
||||||
108
backend/tests/util.py
Normal file
108
backend/tests/util.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Shared helpers: drive the API exactly the way the frontend does."""
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
def hdr(email: str) -> dict[str, str]:
|
||||||
|
return {"X-Dev-User": email}
|
||||||
|
|
||||||
|
|
||||||
|
async def lookup_ids(client: AsyncClient, email: str) -> tuple[int, int]:
|
||||||
|
r = await client.get("/api/lookups", headers=hdr(email))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
return body["departments"][0]["id"], body["deviation_categories"][0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def user_id_by_email(client: AsyncClient, as_email: str, email: str, role: str) -> int:
|
||||||
|
r = await client.get(f"/api/users?role={role}", headers=hdr(as_email))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
for u in r.json():
|
||||||
|
if u["email"] == email:
|
||||||
|
return u["id"]
|
||||||
|
raise AssertionError(f"user {email} with role {role} not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_ncr(client: AsyncClient, team: dict, **overrides) -> dict:
|
||||||
|
dept_id, cat_id = await lookup_ids(client, team["requester"])
|
||||||
|
dispo_id = await user_id_by_email(
|
||||||
|
client, team["requester"], team["dispo"], "disposition_authority"
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"job_number": "J12345",
|
||||||
|
"department_id": dept_id,
|
||||||
|
"deviation_category_id": cat_id,
|
||||||
|
"disposition_authority_id": dispo_id,
|
||||||
|
"deviation_detail": "Bore diameter out of tolerance on 3 pieces.",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
r = await client.post("/api/ncrs", json=payload, headers=hdr(team["requester"]))
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()["ncr"]
|
||||||
|
|
||||||
|
|
||||||
|
async def do_initial_disposition(
|
||||||
|
client: AsyncClient,
|
||||||
|
team: dict,
|
||||||
|
ncr_id: int,
|
||||||
|
*,
|
||||||
|
secondary: bool = False,
|
||||||
|
secondary_ids: list[int] | None = None,
|
||||||
|
as_user: str | None = None,
|
||||||
|
notes: str = "<p>Rework per instructions.</p>",
|
||||||
|
):
|
||||||
|
body = {
|
||||||
|
"qc_authority": "AS9100 8.7",
|
||||||
|
"work_order": "WO-1001",
|
||||||
|
"disposition_notes": notes,
|
||||||
|
"secondary_review_needed": secondary,
|
||||||
|
}
|
||||||
|
if secondary_ids is not None:
|
||||||
|
body["secondary_authority_ids"] = secondary_ids
|
||||||
|
return await client.post(
|
||||||
|
f"/api/ncrs/{ncr_id}/initial-disposition",
|
||||||
|
json=body,
|
||||||
|
headers=hdr(as_user or team["dispo"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def to_operations(client: AsyncClient, team: dict, ncr_id: int) -> dict:
|
||||||
|
"""Walk a fresh NCR straight to the Operations stage."""
|
||||||
|
r = await do_initial_disposition(client, team, ncr_id, secondary=False)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["ncr"]
|
||||||
|
|
||||||
|
|
||||||
|
async def to_qc_inspection(client: AsyncClient, team: dict, ncr_id: int) -> dict:
|
||||||
|
await to_operations(client, team, ncr_id)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr_id}/operations-complete", headers=hdr(team["ops"])
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["ncr"]
|
||||||
|
|
||||||
|
|
||||||
|
async def to_costing(client: AsyncClient, team: dict, ncr_id: int) -> dict:
|
||||||
|
await to_qc_inspection(client, team, ncr_id)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr_id}/inspection",
|
||||||
|
json={"qc_approval": "yes", "inspection_notes": "All good.", "qc_closed": True},
|
||||||
|
headers=hdr(team["qc"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["ncr"]
|
||||||
|
|
||||||
|
|
||||||
|
async def to_closed(client: AsyncClient, team: dict, ncr_id: int) -> dict:
|
||||||
|
await to_costing(client, team, ncr_id)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/ncrs/{ncr_id}/costing",
|
||||||
|
json={
|
||||||
|
"labor_cost": "125.50",
|
||||||
|
"material_cost": "60.00",
|
||||||
|
"service_cost": "0",
|
||||||
|
"other_cost": "14.50",
|
||||||
|
},
|
||||||
|
headers=hdr(team["cost"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["ncr"]
|
||||||
24
db/init/01-powerbi-user.sh
Normal file
24
db/init/01-powerbi-user.sh
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Runs once, on first initialization of the MySQL data volume.
|
||||||
|
# Creates the read-only reporting account used by the Power BI gateway.
|
||||||
|
#
|
||||||
|
# MySQL allows table-level grants on objects that do not exist yet, so the
|
||||||
|
# grants below take effect as soon as Alembic creates the reporting views.
|
||||||
|
# For an already-initialized database, run scripts/powerbi_grants.sql instead
|
||||||
|
# (see README → "Power BI").
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${POWERBI_RO_PASSWORD:-}" ]; then
|
||||||
|
echo "[init] POWERBI_RO_PASSWORD not set - skipping powerbi_ro user creation."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mysql -u root -p"${MYSQL_ROOT_PASSWORD}" <<SQL
|
||||||
|
CREATE USER IF NOT EXISTS 'powerbi_ro'@'%' IDENTIFIED BY '${POWERBI_RO_PASSWORD}';
|
||||||
|
GRANT SELECT ON \`${MYSQL_DATABASE}\`.\`vw_ncr_full\` TO 'powerbi_ro'@'%';
|
||||||
|
GRANT SELECT ON \`${MYSQL_DATABASE}\`.\`vw_ncr_stage_history\` TO 'powerbi_ro'@'%';
|
||||||
|
GRANT SELECT ON \`${MYSQL_DATABASE}\`.\`vw_ncr_costs\` TO 'powerbi_ro'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "[init] powerbi_ro user created with SELECT on reporting views."
|
||||||
87
docker-compose.yml
Normal file
87
docker-compose.yml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
name: pesco-ncr
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-pesco_ncr}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-ncr_app}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||||
|
POWERBI_RO_PASSWORD: ${POWERBI_RO_PASSWORD}
|
||||||
|
ports:
|
||||||
|
# Published so the on-prem Power BI gateway can reach the reporting views.
|
||||||
|
# Remove or firewall this mapping if external reporting access is not needed.
|
||||||
|
- "${MYSQL_PUBLISHED_PORT:-3306}:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
- ./db/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 40s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./backend
|
||||||
|
environment:
|
||||||
|
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8080}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
AUTH_MODE: ${AUTH_MODE:-entra}
|
||||||
|
ENTRA_TENANT_ID: ${ENTRA_TENANT_ID:-}
|
||||||
|
ENTRA_CLIENT_ID: ${ENTRA_CLIENT_ID:-}
|
||||||
|
ENTRA_CLIENT_SECRET: ${ENTRA_CLIENT_SECRET:-}
|
||||||
|
ENTRA_ALLOWED_GROUP_ID: ${ENTRA_ALLOWED_GROUP_ID:-}
|
||||||
|
ENTRA_API_AUDIENCE: ${ENTRA_API_AUDIENCE:-}
|
||||||
|
INITIAL_ADMIN_EMAILS: ${INITIAL_ADMIN_EMAILS:-}
|
||||||
|
MYSQL_HOST: mysql
|
||||||
|
MYSQL_PORT: 3306
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-pesco_ncr}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-ncr_app}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||||
|
ATTACHMENTS_DIR: ${ATTACHMENTS_DIR:-/data/attachments}
|
||||||
|
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-25}
|
||||||
|
NOTIFICATIONS_ENABLED_DEFAULT: ${NOTIFICATIONS_ENABLED_DEFAULT:-true}
|
||||||
|
JOB_LOOKUP_PROVIDER: ${JOB_LOOKUP_PROVIDER:-null}
|
||||||
|
VISUAL_DB_HOST: ${VISUAL_DB_HOST:-}
|
||||||
|
VISUAL_DB_PORT: ${VISUAL_DB_PORT:-1433}
|
||||||
|
VISUAL_DB_NAME: ${VISUAL_DB_NAME:-}
|
||||||
|
VISUAL_DB_USER: ${VISUAL_DB_USER:-}
|
||||||
|
VISUAL_DB_PASSWORD: ${VISUAL_DB_PASSWORD:-}
|
||||||
|
VISUAL_SITE_ID: ${VISUAL_SITE_ID:-}
|
||||||
|
SEED_DEMO_DATA: ${SEED_DEMO_DATA:-false}
|
||||||
|
volumes:
|
||||||
|
- attachments_data:/data/attachments
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:-8080}:80"
|
||||||
|
environment:
|
||||||
|
AUTH_MODE: ${AUTH_MODE:-entra}
|
||||||
|
ENTRA_TENANT_ID: ${ENTRA_TENANT_ID:-}
|
||||||
|
ENTRA_CLIENT_ID: ${ENTRA_CLIENT_ID:-}
|
||||||
|
ENTRA_API_SCOPE: ${ENTRA_API_SCOPE:-}
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
attachments_data:
|
||||||
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.tsbuildinfo
|
||||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci 2>/dev/null || npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY docker-entrypoint.d/50-config.sh /docker-entrypoint.d/50-config.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/50-config.sh
|
||||||
|
EXPOSE 80
|
||||||
20
frontend/docker-entrypoint.d/50-config.sh
Normal file
20
frontend/docker-entrypoint.d/50-config.sh
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Generates the SPA's runtime configuration from container environment
|
||||||
|
# variables (nginx image runs every /docker-entrypoint.d/*.sh on start).
|
||||||
|
set -e
|
||||||
|
|
||||||
|
API_SCOPE="${ENTRA_API_SCOPE}"
|
||||||
|
if [ -z "$API_SCOPE" ] && [ -n "$ENTRA_CLIENT_ID" ]; then
|
||||||
|
API_SCOPE="api://${ENTRA_CLIENT_ID}/access_as_user"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > /usr/share/nginx/html/config.js <<EOF
|
||||||
|
window.__APP_CONFIG__ = {
|
||||||
|
authMode: "${AUTH_MODE:-entra}",
|
||||||
|
tenantId: "${ENTRA_TENANT_ID}",
|
||||||
|
clientId: "${ENTRA_CLIENT_ID}",
|
||||||
|
apiScope: "${API_SCOPE}"
|
||||||
|
};
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "[frontend] config.js generated (authMode=${AUTH_MODE:-entra})"
|
||||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#1a5fb4" />
|
||||||
|
<title>PESCO NCR</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='18' fill='%231a5fb4'/><text x='50' y='68' font-size='52' text-anchor='middle' fill='white' font-family='Arial' font-weight='bold'>N</text></svg>" />
|
||||||
|
<!-- Runtime configuration, generated from env by the nginx entrypoint -->
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
frontend/nginx.conf
Normal file
33
frontend/nginx.conf
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Attachment uploads flow through this proxy; keep in sync with MAX_UPLOAD_MB.
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /config.js {
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
3671
frontend/package-lock.json
generated
Normal file
3671
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "pesco-ncr-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/msal-browser": "^3.26.1",
|
||||||
|
"@azure/msal-react": "^2.1.1",
|
||||||
|
"@emotion/react": "^11.13.3",
|
||||||
|
"@emotion/styled": "^11.13.0",
|
||||||
|
"@mui/icons-material": "^5.16.7",
|
||||||
|
"@mui/material": "^5.16.7",
|
||||||
|
"@tanstack/react-query": "^5.59.0",
|
||||||
|
"@tiptap/extension-link": "^2.9.1",
|
||||||
|
"@tiptap/react": "^2.9.1",
|
||||||
|
"@tiptap/starter-kit": "^2.9.1",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
|
"recharts": "^2.13.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.7.4",
|
||||||
|
"@types/react": "^18.3.10",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"typescript": "~5.5.4",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/public/config.js
Normal file
8
frontend/public/config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Local development defaults. In Docker this file is REPLACED at container
|
||||||
|
// start by docker-entrypoint.d/50-config.sh using the real environment.
|
||||||
|
window.__APP_CONFIG__ = {
|
||||||
|
authMode: "dev",
|
||||||
|
tenantId: "",
|
||||||
|
clientId: "",
|
||||||
|
apiScope: "",
|
||||||
|
};
|
||||||
24
frontend/src/App.tsx
Normal file
24
frontend/src/App.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { Layout } from "./components/Layout";
|
||||||
|
import { AdminPage } from "./pages/admin/AdminPage";
|
||||||
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
|
import { NcrDetailPage } from "./pages/NcrDetailPage";
|
||||||
|
import { NewNcrPage } from "./pages/NewNcrPage";
|
||||||
|
import { ReportsPage } from "./pages/ReportsPage";
|
||||||
|
import { SearchPage } from "./pages/SearchPage";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/ncrs/new" element={<NewNcrPage />} />
|
||||||
|
<Route path="/ncrs/:id" element={<NcrDetailPage />} />
|
||||||
|
<Route path="/search" element={<SearchPage />} />
|
||||||
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
|
<Route path="/admin/*" element={<AdminPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
frontend/src/api/client.ts
Normal file
135
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
InteractionRequiredAuthError,
|
||||||
|
PublicClientApplication,
|
||||||
|
} from "@azure/msal-browser";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
|
export const msalInstance =
|
||||||
|
config.authMode === "entra"
|
||||||
|
? new PublicClientApplication({
|
||||||
|
auth: {
|
||||||
|
clientId: config.clientId,
|
||||||
|
authority: `https://login.microsoftonline.com/${config.tenantId}`,
|
||||||
|
redirectUri: window.location.origin,
|
||||||
|
postLogoutRedirectUri: window.location.origin,
|
||||||
|
},
|
||||||
|
cache: { cacheLocation: "sessionStorage" },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const DEV_USER_KEY = "pesco-ncr-dev-user";
|
||||||
|
|
||||||
|
export function getDevUser(): string {
|
||||||
|
return localStorage.getItem(DEV_USER_KEY) || "admin@pescoinc.biz";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDevUser(email: string): void {
|
||||||
|
localStorage.setItem(DEV_USER_KEY, email);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authHeaders(): Promise<Record<string, string>> {
|
||||||
|
if (config.authMode === "dev") {
|
||||||
|
return { "X-Dev-User": getDevUser() };
|
||||||
|
}
|
||||||
|
const instance = msalInstance!;
|
||||||
|
const account = instance.getActiveAccount() ?? instance.getAllAccounts()[0];
|
||||||
|
if (!account) {
|
||||||
|
await instance.loginRedirect({ scopes: [config.apiScope] });
|
||||||
|
throw new Error("Redirecting to sign in…");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await instance.acquireTokenSilent({
|
||||||
|
scopes: [config.apiScope],
|
||||||
|
account,
|
||||||
|
});
|
||||||
|
return { Authorization: `Bearer ${result.accessToken}` };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InteractionRequiredAuthError) {
|
||||||
|
await instance.acquireTokenRedirect({ scopes: [config.apiScope], account });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(status: number, detail: string) {
|
||||||
|
super(detail);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseError(resp: Response): Promise<ApiError> {
|
||||||
|
let detail = `Request failed (${resp.status})`;
|
||||||
|
try {
|
||||||
|
const body = await resp.json();
|
||||||
|
if (typeof body.detail === "string") detail = body.detail;
|
||||||
|
else if (Array.isArray(body.detail) && body.detail[0]?.msg)
|
||||||
|
detail = body.detail
|
||||||
|
.map((d: { loc?: unknown[]; msg: string }) => d.msg)
|
||||||
|
.join("; ");
|
||||||
|
} catch {
|
||||||
|
/* non-JSON body */
|
||||||
|
}
|
||||||
|
return new ApiError(resp.status, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T>(
|
||||||
|
path: string,
|
||||||
|
options: { method?: string; body?: unknown } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = await authHeaders();
|
||||||
|
const init: RequestInit = { method: options.method ?? "GET", headers };
|
||||||
|
if (options.body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
init.body = JSON.stringify(options.body);
|
||||||
|
}
|
||||||
|
const resp = await fetch(path, init);
|
||||||
|
if (!resp.ok) throw await parseError(resp);
|
||||||
|
if (resp.status === 204) return undefined as T;
|
||||||
|
return (await resp.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiUpload<T>(path: string, form: FormData): Promise<T> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const resp = await fetch(path, { method: "POST", headers, body: form });
|
||||||
|
if (!resp.ok) throw await parseError(resp);
|
||||||
|
return (await resp.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiBlob(path: string): Promise<Blob> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const resp = await fetch(path, { headers });
|
||||||
|
if (!resp.ok) throw await parseError(resp);
|
||||||
|
return resp.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch a protected file and hand it to the browser (download or new tab). */
|
||||||
|
export async function openBlob(
|
||||||
|
path: string,
|
||||||
|
filename: string,
|
||||||
|
mode: "download" | "open",
|
||||||
|
): Promise<void> {
|
||||||
|
const blob = await apiBlob(path);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (mode === "open") {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
} else {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildQuery(params: Record<string, unknown>): string {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
q.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const s = q.toString();
|
||||||
|
return s ? `?${s}` : "";
|
||||||
|
}
|
||||||
121
frontend/src/api/hooks.ts
Normal file
121
frontend/src/api/hooks.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { api, apiUpload, buildQuery } from "./client";
|
||||||
|
import type {
|
||||||
|
AttachmentOut,
|
||||||
|
AuditListOut,
|
||||||
|
JobLookupOut,
|
||||||
|
LookupsOut,
|
||||||
|
MeOut,
|
||||||
|
NcrDetail,
|
||||||
|
NcrListOut,
|
||||||
|
NcrMutationOut,
|
||||||
|
QueueFilters,
|
||||||
|
ReportsSummary,
|
||||||
|
UserOut,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export function useMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: () => api<MeOut>("/api/me"),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLookups() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["lookups"],
|
||||||
|
queryFn: () => api<LookupsOut>("/api/lookups"),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsersByRole(role: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["users", role],
|
||||||
|
queryFn: () => api<UserOut[]>(`/api/users?role=${role}`),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQueue(queue: string, filters: QueueFilters, page: number, pageSize = 25) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ncrs", queue, filters, page, pageSize],
|
||||||
|
queryFn: () =>
|
||||||
|
api<NcrListOut>(
|
||||||
|
`/api/ncrs${buildQuery({ queue, page, page_size: pageSize, ...filters })}`,
|
||||||
|
),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNcr(id: number | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ncr", id],
|
||||||
|
queryFn: () => api<NcrDetail>(`/api/ncrs/${id}`),
|
||||||
|
enabled: id !== undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNcrAudit(id: number, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ncr-audit", id],
|
||||||
|
queryFn: () => api<AuditListOut>(`/api/ncrs/${id}/audit`),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useJobLookup(jobNumber: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["job-lookup", jobNumber],
|
||||||
|
queryFn: () =>
|
||||||
|
api<JobLookupOut>(`/api/jobs/${encodeURIComponent(jobNumber)}/lookup`),
|
||||||
|
enabled: jobNumber.trim().length > 2,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReportsSummary(filters: Record<string, unknown>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["reports", filters],
|
||||||
|
queryFn: () =>
|
||||||
|
api<ReportsSummary>(`/api/reports/summary${buildQuery(filters)}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared invalidation + warning plumbing for every NCR mutation. */
|
||||||
|
export function useNcrMutation<TVars>(
|
||||||
|
mutationFn: (vars: TVars) => Promise<NcrMutationOut>,
|
||||||
|
onWarnings?: (warnings: string[]) => void,
|
||||||
|
) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
qc.setQueryData(["ncr", data.ncr.id], data.ncr);
|
||||||
|
qc.invalidateQueries({ queryKey: ["ncrs"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["ncr-audit", data.ncr.id] });
|
||||||
|
if (data.warnings.length && onWarnings) onWarnings(data.warnings);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadAttachments(ncrId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (files: File[]) => {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const f of files) form.append("files", f, f.name);
|
||||||
|
return apiUpload<AttachmentOut[]>(`/api/ncrs/${ncrId}/attachments`, form);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["ncr", ncrId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["ncr-audit", ncrId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
246
frontend/src/api/types.ts
Normal file
246
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
export type StageValue =
|
||||||
|
| "new_request"
|
||||||
|
| "secondary_disposition"
|
||||||
|
| "operations"
|
||||||
|
| "qc_inspection"
|
||||||
|
| "costing"
|
||||||
|
| "closed";
|
||||||
|
|
||||||
|
export const STAGE_LABELS: Record<StageValue, string> = {
|
||||||
|
new_request: "New Request",
|
||||||
|
secondary_disposition: "Secondary Disposition",
|
||||||
|
operations: "Operations",
|
||||||
|
qc_inspection: "QC Inspection",
|
||||||
|
costing: "Costing",
|
||||||
|
closed: "Closed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STAGE_ORDER: StageValue[] = [
|
||||||
|
"new_request",
|
||||||
|
"secondary_disposition",
|
||||||
|
"operations",
|
||||||
|
"qc_inspection",
|
||||||
|
"costing",
|
||||||
|
"closed",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ROLES = [
|
||||||
|
"requester",
|
||||||
|
"disposition_authority",
|
||||||
|
"secondary_disposition_authority",
|
||||||
|
"operations",
|
||||||
|
"qc_inspector",
|
||||||
|
"costing",
|
||||||
|
"admin",
|
||||||
|
] as const;
|
||||||
|
export type Role = (typeof ROLES)[number];
|
||||||
|
|
||||||
|
export const ROLE_LABELS: Record<Role, string> = {
|
||||||
|
requester: "Requester",
|
||||||
|
disposition_authority: "Disposition Authority",
|
||||||
|
secondary_disposition_authority: "Secondary Disposition Authority",
|
||||||
|
operations: "Operations",
|
||||||
|
qc_inspector: "QC Inspector",
|
||||||
|
costing: "Costing",
|
||||||
|
admin: "Admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UserRef {
|
||||||
|
id: number;
|
||||||
|
display_name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserOut extends UserRef {
|
||||||
|
employee_id: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
roles: Role[];
|
||||||
|
last_login_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeOut extends UserOut {
|
||||||
|
auth_mode: "entra" | "dev";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NamedLookup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LookupsOut {
|
||||||
|
departments: NamedLookup[];
|
||||||
|
deviation_categories: NamedLookup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentOut {
|
||||||
|
id: number;
|
||||||
|
original_filename: string;
|
||||||
|
content_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
is_image: boolean;
|
||||||
|
uploaded_at: string;
|
||||||
|
uploaded_by: UserRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransitionOut {
|
||||||
|
id: number;
|
||||||
|
from_stage: StageValue | null;
|
||||||
|
to_stage: StageValue;
|
||||||
|
action: string;
|
||||||
|
acted_at: string;
|
||||||
|
acted_by: UserRef;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobInfoOut {
|
||||||
|
part_id: string | null;
|
||||||
|
part_description: string | null;
|
||||||
|
customer_name: string | null;
|
||||||
|
work_order_status: string | null;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NcrListItem {
|
||||||
|
id: number;
|
||||||
|
ncr_number: string;
|
||||||
|
job_number: string;
|
||||||
|
department: string;
|
||||||
|
deviation_category: string;
|
||||||
|
requester: string;
|
||||||
|
disposition_authority: string;
|
||||||
|
stage: StageValue;
|
||||||
|
stage_label: string;
|
||||||
|
days_in_stage: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NcrListOut {
|
||||||
|
items: NcrListItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NcrAction =
|
||||||
|
| "initial_disposition"
|
||||||
|
| "secondary_disposition"
|
||||||
|
| "operations_complete"
|
||||||
|
| "inspection"
|
||||||
|
| "costing"
|
||||||
|
| "reopen"
|
||||||
|
| "add_attachment"
|
||||||
|
| "view_audit";
|
||||||
|
|
||||||
|
export interface NcrDetail {
|
||||||
|
id: number;
|
||||||
|
ncr_number: string;
|
||||||
|
job_number: string;
|
||||||
|
created_at: string;
|
||||||
|
stage: StageValue;
|
||||||
|
stage_label: string;
|
||||||
|
stage_entered_at: string;
|
||||||
|
days_in_stage: number;
|
||||||
|
department: string;
|
||||||
|
department_id: number;
|
||||||
|
deviation_category: string;
|
||||||
|
deviation_category_id: number;
|
||||||
|
deviation_detail: string;
|
||||||
|
requester: UserRef;
|
||||||
|
disposition_authority: UserRef;
|
||||||
|
qc_authority: string | null;
|
||||||
|
work_order: string | null;
|
||||||
|
disposition_notes: string | null;
|
||||||
|
secondary_review_needed: boolean | null;
|
||||||
|
secondary_authorities: UserRef[];
|
||||||
|
operations_complete: boolean;
|
||||||
|
operations_completed_at: string | null;
|
||||||
|
operations_completed_by: UserRef | null;
|
||||||
|
qc_approval: "yes" | "no" | null;
|
||||||
|
inspection_notes: string | null;
|
||||||
|
qc_closed: boolean;
|
||||||
|
qc_closed_at: string | null;
|
||||||
|
qc_closed_by: UserRef | null;
|
||||||
|
labor_cost: string | null;
|
||||||
|
material_cost: string | null;
|
||||||
|
service_cost: string | null;
|
||||||
|
other_cost: string | null;
|
||||||
|
total_cost: string | null;
|
||||||
|
costing_completed_at: string | null;
|
||||||
|
costing_completed_by: UserRef | null;
|
||||||
|
closed_at: string | null;
|
||||||
|
closed_by: UserRef | null;
|
||||||
|
job_info: JobInfoOut | null;
|
||||||
|
attachments: AttachmentOut[];
|
||||||
|
transitions: TransitionOut[];
|
||||||
|
available_actions: NcrAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NcrMutationOut {
|
||||||
|
ncr: NcrDetail;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: number;
|
||||||
|
created_at: string;
|
||||||
|
user: UserRef;
|
||||||
|
action: string;
|
||||||
|
field_name: string | null;
|
||||||
|
old_value: string | null;
|
||||||
|
new_value: string | null;
|
||||||
|
detail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditListOut {
|
||||||
|
items: AuditEntry[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueFilters {
|
||||||
|
q?: string;
|
||||||
|
job_number?: string;
|
||||||
|
department_id?: number;
|
||||||
|
category_id?: number;
|
||||||
|
stage?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
disposition_authority_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportsSummary {
|
||||||
|
total_ncrs: number;
|
||||||
|
open_ncrs: number;
|
||||||
|
closed_ncrs: number;
|
||||||
|
total_cost: string;
|
||||||
|
by_department: { name: string; count: number }[];
|
||||||
|
by_category: { name: string; count: number }[];
|
||||||
|
by_month: { month: string; count: number }[];
|
||||||
|
cost_over_time: {
|
||||||
|
month: string;
|
||||||
|
labor: string;
|
||||||
|
material: string;
|
||||||
|
service: string;
|
||||||
|
other: string;
|
||||||
|
total: string;
|
||||||
|
}[];
|
||||||
|
aging: { bucket: string; count: number }[];
|
||||||
|
cycle_times: {
|
||||||
|
stage: StageValue;
|
||||||
|
stage_label: string;
|
||||||
|
avg_days: number;
|
||||||
|
samples: number;
|
||||||
|
}[];
|
||||||
|
end_to_end_avg_days: number | null;
|
||||||
|
top_jobs: { job_number: string; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobLookupOut {
|
||||||
|
found: boolean;
|
||||||
|
job_number: string;
|
||||||
|
part_id?: string | null;
|
||||||
|
part_description?: string | null;
|
||||||
|
customer_name?: string | null;
|
||||||
|
work_order_status?: string | null;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
117
frontend/src/auth/AuthGate.tsx
Normal file
117
frontend/src/auth/AuthGate.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { MsalProvider, useIsAuthenticated, useMsal } from "@azure/msal-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { msalInstance } from "../api/client";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
import { useMe } from "../api/hooks";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
|
function Centered({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
p: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={2} alignItems="center" sx={{ maxWidth: 480 }}>
|
||||||
|
{children}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After sign-in (or in dev mode), /api/me must succeed before the app loads:
|
||||||
|
* it auto-provisions the user and enforces the front-door group. */
|
||||||
|
function MeGate({ children }: { children: ReactNode }) {
|
||||||
|
const me = useMe();
|
||||||
|
|
||||||
|
if (me.isLoading) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography color="text.secondary">Signing you in…</Typography>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (me.isError) {
|
||||||
|
const err = me.error;
|
||||||
|
const detail =
|
||||||
|
err instanceof ApiError ? err.message : "Could not reach the NCR API.";
|
||||||
|
const denied = err instanceof ApiError && err.status === 403;
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<Typography variant="h5">PESCO NCR</Typography>
|
||||||
|
<Alert severity={denied ? "warning" : "error"} sx={{ width: "100%" }}>
|
||||||
|
{denied ? "Access denied. " : ""}
|
||||||
|
{detail}
|
||||||
|
</Alert>
|
||||||
|
{denied && (
|
||||||
|
<Typography color="text.secondary" variant="body2">
|
||||||
|
Ask IT to add you to the NCR access group, then sign in again.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button variant="contained" onClick={() => me.refetch()}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntraGate({ children }: { children: ReactNode }) {
|
||||||
|
const isAuthenticated = useIsAuthenticated();
|
||||||
|
const { instance, inProgress } = useMsal();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated && inProgress === "none") {
|
||||||
|
void instance.loginRedirect({ scopes: [config.apiScope] });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, inProgress, instance]);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Redirecting to Microsoft sign-in…
|
||||||
|
</Typography>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <MeGate>{children}</MeGate>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGate({ children }: { children: ReactNode }) {
|
||||||
|
if (config.authMode === "dev") {
|
||||||
|
return <MeGate>{children}</MeGate>;
|
||||||
|
}
|
||||||
|
if (!config.clientId || !config.tenantId) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<Typography variant="h5">PESCO NCR</Typography>
|
||||||
|
<Alert severity="error">
|
||||||
|
Entra ID is not configured. Set ENTRA_TENANT_ID and ENTRA_CLIENT_ID in
|
||||||
|
.env (see README), or set AUTH_MODE=dev for local development.
|
||||||
|
</Alert>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<MsalProvider instance={msalInstance!}>
|
||||||
|
<EntraGate>{children}</EntraGate>
|
||||||
|
</MsalProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
frontend/src/components/AttachmentSection.tsx
Normal file
189
frontend/src/components/AttachmentSection.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import AttachFileIcon from "@mui/icons-material/AttachFile";
|
||||||
|
import DescriptionIcon from "@mui/icons-material/Description";
|
||||||
|
import PhotoCameraIcon from "@mui/icons-material/PhotoCamera";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
Stack,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { apiBlob, openBlob } from "../api/client";
|
||||||
|
import { useUploadAttachments } from "../api/hooks";
|
||||||
|
import type { AttachmentOut } from "../api/types";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
|
/** Images are behind the authenticated API, so <img src> can't load them
|
||||||
|
* directly — fetch as a blob and use an object URL. */
|
||||||
|
function useAttachmentUrl(att: AttachmentOut, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["attachment-blob", att.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const blob = await apiBlob(`/api/attachments/${att.id}/download`);
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Thumbnail({ att, onOpen }: { att: AttachmentOut; onOpen: (url: string) => void }) {
|
||||||
|
const url = useAttachmentUrl(att, att.is_image);
|
||||||
|
|
||||||
|
if (!att.is_image) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={`${att.original_filename} — click to download`}>
|
||||||
|
<Box
|
||||||
|
onClick={() => void openBlob(`/api/attachments/${att.id}/download`, att.original_filename, "download")}
|
||||||
|
sx={{
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
p: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DescriptionIcon color="action" />
|
||||||
|
<Typography variant="caption" noWrap sx={{ maxWidth: 88 }}>
|
||||||
|
{att.original_filename}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={`${att.original_filename} — ${att.uploaded_by.display_name}, ${new Date(att.uploaded_at).toLocaleString()}`}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onClick={() => url.data && onOpen(url.data)}
|
||||||
|
sx={{
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: "#fafafa",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url.data ? (
|
||||||
|
<img
|
||||||
|
src={url.data}
|
||||||
|
alt={att.original_filename}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ncrId: number;
|
||||||
|
attachments: AttachmentOut[];
|
||||||
|
canAdd: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentSection({ ncrId, attachments, canAdd }: Props) {
|
||||||
|
const upload = useUploadAttachments(ncrId);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const cameraInput = useRef<HTMLInputElement>(null);
|
||||||
|
const [lightbox, setLightbox] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleFiles = (list: FileList | null) => {
|
||||||
|
if (!list || list.length === 0) return;
|
||||||
|
upload.mutate(Array.from(list), {
|
||||||
|
onSuccess: (items) =>
|
||||||
|
toast(`${items.length} attachment${items.length > 1 ? "s" : ""} added.`),
|
||||||
|
onError: (err) => toast(err.message, "error"),
|
||||||
|
});
|
||||||
|
if (fileInput.current) fileInput.current.value = "";
|
||||||
|
if (cameraInput.current) cameraInput.current.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<Thumbnail key={att.id} att={att} onOpen={setLightbox} />
|
||||||
|
))}
|
||||||
|
{attachments.length === 0 && (
|
||||||
|
<Typography color="text.secondary" variant="body2">
|
||||||
|
No attachments yet.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{canAdd && (
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
startIcon={<PhotoCameraIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => cameraInput.current?.click()}
|
||||||
|
disabled={upload.isPending}
|
||||||
|
>
|
||||||
|
Take Photo
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={upload.isPending ? <CircularProgress size={16} /> : <AttachFileIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
disabled={upload.isPending}
|
||||||
|
>
|
||||||
|
Add Files
|
||||||
|
</Button>
|
||||||
|
{/* capture="environment" opens the rear camera on tablets/phones */}
|
||||||
|
<input
|
||||||
|
ref={cameraInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.msg,.eml"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={lightbox !== null} onClose={() => setLightbox(null)} maxWidth="lg">
|
||||||
|
<DialogContent sx={{ p: 0.5 }}>
|
||||||
|
{lightbox && (
|
||||||
|
<img
|
||||||
|
src={lightbox}
|
||||||
|
alt="attachment"
|
||||||
|
style={{ maxWidth: "90vw", maxHeight: "85vh", display: "block" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/components/FieldRow.tsx
Normal file
14
frontend/src/components/FieldRow.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Grid, Typography } from "@mui/material";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Label/value pair used across the NCR detail read-only sections. */
|
||||||
|
export function FieldRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Grid item xs={12} sm={6} md={4}>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography component="div">{children || "—"}</Typography>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/JobNumberField.tsx
Normal file
46
frontend/src/components/JobNumberField.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Chip, Stack, TextField } from "@mui/material";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useJobLookup } from "../api/hooks";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Job number entry. Free text today (NullJobLookupService); when the VISUAL
|
||||||
|
* provider is enabled the enrichment chips below light up automatically —
|
||||||
|
* no redesign needed. */
|
||||||
|
export function JobNumberField({ value, onChange, required }: Props) {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(value), 400);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const lookup = useJobLookup(debounced);
|
||||||
|
const info = lookup.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<TextField
|
||||||
|
label="Job Number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
required={required}
|
||||||
|
inputProps={{ maxLength: 100 }}
|
||||||
|
/>
|
||||||
|
{info?.found && (
|
||||||
|
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||||
|
{info.part_id && <Chip size="small" label={`Part: ${info.part_id}`} />}
|
||||||
|
{info.customer_name && (
|
||||||
|
<Chip size="small" label={`Customer: ${info.customer_name}`} />
|
||||||
|
)}
|
||||||
|
{info.work_order_status && (
|
||||||
|
<Chip size="small" label={`WO Status: ${info.work_order_status}`} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend/src/components/Layout.tsx
Normal file
197
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import AddCircleIcon from "@mui/icons-material/AddCircle";
|
||||||
|
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
|
||||||
|
import AssessmentIcon from "@mui/icons-material/Assessment";
|
||||||
|
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||||
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Toolbar,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { getDevUser, setDevUser } from "../api/client";
|
||||||
|
import { useMe } from "../api/hooks";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
|
const DRAWER_WIDTH = 232;
|
||||||
|
|
||||||
|
const DEV_USERS = [
|
||||||
|
"admin@pescoinc.biz",
|
||||||
|
"dispo@pescoinc.biz",
|
||||||
|
"second@pescoinc.biz",
|
||||||
|
"ops@pescoinc.biz",
|
||||||
|
"qc@pescoinc.biz",
|
||||||
|
"cost@pescoinc.biz",
|
||||||
|
"req@pescoinc.biz",
|
||||||
|
];
|
||||||
|
|
||||||
|
function DevUserSwitcher() {
|
||||||
|
return (
|
||||||
|
<Tooltip title="AUTH_MODE=dev — switch the simulated user">
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={getDevUser()}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDevUser(e.target.value);
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
mr: 1,
|
||||||
|
bgcolor: "rgba(255,255,255,0.15)",
|
||||||
|
color: "#fff",
|
||||||
|
".MuiSvgIcon-root": { color: "#fff" },
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DEV_USERS.map((u) => (
|
||||||
|
<MenuItem key={u} value={u}>
|
||||||
|
{u.split("@")[0]}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isDesktop = useMediaQuery(theme.breakpoints.up("md"));
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const me = useMe();
|
||||||
|
const isAdmin = me.data?.roles.includes("admin") ?? false;
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ label: "Dashboard", icon: <DashboardIcon />, path: "/" },
|
||||||
|
{ label: "New NCR", icon: <AddCircleIcon />, path: "/ncrs/new" },
|
||||||
|
{ label: "Search", icon: <SearchIcon />, path: "/search" },
|
||||||
|
{ label: "Reports", icon: <AssessmentIcon />, path: "/reports" },
|
||||||
|
...(isAdmin
|
||||||
|
? [{ label: "Admin", icon: <AdminPanelSettingsIcon />, path: "/admin" }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const drawer = (
|
||||||
|
<Box sx={{ pt: 1 }}>
|
||||||
|
<Toolbar sx={{ minHeight: { xs: 56, md: 64 } }}>
|
||||||
|
<Typography variant="h6" color="primary">
|
||||||
|
PESCO NCR
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
{nav.map((item) => {
|
||||||
|
const selected =
|
||||||
|
item.path === "/"
|
||||||
|
? location.pathname === "/"
|
||||||
|
: location.pathname.startsWith(item.path);
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
key={item.path}
|
||||||
|
selected={selected}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(item.path);
|
||||||
|
setMobileOpen(false);
|
||||||
|
}}
|
||||||
|
sx={{ minHeight: 48 }}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.label} />
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
||||||
|
<AppBar
|
||||||
|
position="fixed"
|
||||||
|
sx={{ zIndex: theme.zIndex.drawer + 1 }}
|
||||||
|
elevation={1}
|
||||||
|
>
|
||||||
|
<Toolbar sx={{ minHeight: { xs: 56, md: 64 } }}>
|
||||||
|
{!isDesktop && (
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
edge="start"
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<Typography variant="h6" sx={{ flexGrow: 1 }} noWrap>
|
||||||
|
Non-Conformance Reports
|
||||||
|
</Typography>
|
||||||
|
{config.authMode === "dev" && <DevUserSwitcher />}
|
||||||
|
{me.data && (
|
||||||
|
<Tooltip title={`${me.data.display_name} (${me.data.email})`}>
|
||||||
|
<Avatar sx={{ bgcolor: "secondary.main", width: 36, height: 36 }}>
|
||||||
|
{me.data.display_name
|
||||||
|
.split(" ")
|
||||||
|
.map((p) => p[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("")}
|
||||||
|
</Avatar>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
{isDesktop ? (
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
"& .MuiDrawer-paper": { width: DRAWER_WIDTH, boxSizing: "border-box" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
) : (
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={() => setMobileOpen(false)}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
sx={{ "& .MuiDrawer-paper": { width: DRAWER_WIDTH } }}
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
p: { xs: 1.5, sm: 2, md: 3 },
|
||||||
|
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||||
|
mt: { xs: "56px", md: "64px" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
frontend/src/components/QueueTable.tsx
Normal file
144
frontend/src/components/QueueTable.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardActionArea,
|
||||||
|
CardContent,
|
||||||
|
CircularProgress,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { NcrListItem } from "../api/types";
|
||||||
|
import { StageChip } from "./StageChip";
|
||||||
|
|
||||||
|
function fmtDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: NcrListItem[];
|
||||||
|
total: number;
|
||||||
|
page: number; // 1-based
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Responsive queue view: a dense table on desktop, tap-friendly cards on
|
||||||
|
* phones/tablets in portrait. */
|
||||||
|
export function QueueTable({ items, total, page, pageSize, onPageChange, loading }: Props) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (loading && items.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ py: 6, textAlign: "center" }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: "center" }}>
|
||||||
|
No NCRs in this queue.
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagination = (
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={total}
|
||||||
|
page={page - 1}
|
||||||
|
onPageChange={(_, p) => onPageChange(p + 1)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
rowsPerPageOptions={[pageSize]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSmall) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{items.map((n) => (
|
||||||
|
<Card key={n.id} variant="outlined">
|
||||||
|
<CardActionArea onClick={() => navigate(`/ncrs/${n.id}`)}>
|
||||||
|
<CardContent sx={{ py: 1.5 }}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Typography fontWeight={700}>{n.ncr_number}</Typography>
|
||||||
|
<StageChip stage={n.stage} />
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Job {n.job_number} · {n.department} · {n.deviation_category}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{n.requester} · {fmtDate(n.created_at)} · {n.days_in_stage}d in
|
||||||
|
stage
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{pagination}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>NCR #</TableCell>
|
||||||
|
<TableCell>Job #</TableCell>
|
||||||
|
<TableCell>Department</TableCell>
|
||||||
|
<TableCell>Requester</TableCell>
|
||||||
|
<TableCell>Category</TableCell>
|
||||||
|
<TableCell>Stage</TableCell>
|
||||||
|
<TableCell align="right">Days in Stage</TableCell>
|
||||||
|
<TableCell align="right">Created</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((n) => (
|
||||||
|
<TableRow
|
||||||
|
key={n.id}
|
||||||
|
hover
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/ncrs/${n.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>{n.ncr_number}</TableCell>
|
||||||
|
<TableCell>{n.job_number}</TableCell>
|
||||||
|
<TableCell>{n.department}</TableCell>
|
||||||
|
<TableCell>{n.requester}</TableCell>
|
||||||
|
<TableCell>{n.deviation_category}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StageChip stage={n.stage} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">{n.days_in_stage}</TableCell>
|
||||||
|
<TableCell align="right">{fmtDate(n.created_at)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
{pagination}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
frontend/src/components/RichTextEditor.tsx
Normal file
141
frontend/src/components/RichTextEditor.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import FormatBoldIcon from "@mui/icons-material/FormatBold";
|
||||||
|
import FormatItalicIcon from "@mui/icons-material/FormatItalic";
|
||||||
|
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
|
||||||
|
import FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered";
|
||||||
|
import LinkIcon from "@mui/icons-material/Link";
|
||||||
|
import RedoIcon from "@mui/icons-material/Redo";
|
||||||
|
import StrikethroughSIcon from "@mui/icons-material/StrikethroughS";
|
||||||
|
import UndoIcon from "@mui/icons-material/Undo";
|
||||||
|
import { Box, Divider, ToggleButton, Typography } from "@mui/material";
|
||||||
|
import Link from "@tiptap/extension-link";
|
||||||
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (html: string) => void;
|
||||||
|
minHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rich-text editor for disposition notes. Output HTML is sanitized again
|
||||||
|
* server-side (nh3) before storage. */
|
||||||
|
export function RichTextEditor({ label, value, onChange, minHeight = 140 }: Props) {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Link.configure({ openOnClick: false, autolink: true }),
|
||||||
|
],
|
||||||
|
content: value,
|
||||||
|
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && value !== editor.getHTML() && !editor.isFocused) {
|
||||||
|
editor.commands.setContent(value || "", false);
|
||||||
|
}
|
||||||
|
}, [value, editor]);
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
const btn = (
|
||||||
|
active: boolean,
|
||||||
|
onClick: () => void,
|
||||||
|
icon: React.ReactNode,
|
||||||
|
title: string,
|
||||||
|
) => (
|
||||||
|
<ToggleButton
|
||||||
|
value={title}
|
||||||
|
selected={active}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
sx={{ border: 0, px: 1 }}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</ToggleButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{label && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 1,
|
||||||
|
"&:focus-within": { borderColor: "primary.main" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", flexWrap: "wrap", p: 0.5, gap: 0.25 }}>
|
||||||
|
{btn(
|
||||||
|
editor.isActive("bold"),
|
||||||
|
() => editor.chain().focus().toggleBold().run(),
|
||||||
|
<FormatBoldIcon fontSize="small" />,
|
||||||
|
"Bold",
|
||||||
|
)}
|
||||||
|
{btn(
|
||||||
|
editor.isActive("italic"),
|
||||||
|
() => editor.chain().focus().toggleItalic().run(),
|
||||||
|
<FormatItalicIcon fontSize="small" />,
|
||||||
|
"Italic",
|
||||||
|
)}
|
||||||
|
{btn(
|
||||||
|
editor.isActive("strike"),
|
||||||
|
() => editor.chain().focus().toggleStrike().run(),
|
||||||
|
<StrikethroughSIcon fontSize="small" />,
|
||||||
|
"Strikethrough",
|
||||||
|
)}
|
||||||
|
{btn(
|
||||||
|
editor.isActive("bulletList"),
|
||||||
|
() => editor.chain().focus().toggleBulletList().run(),
|
||||||
|
<FormatListBulletedIcon fontSize="small" />,
|
||||||
|
"Bullet list",
|
||||||
|
)}
|
||||||
|
{btn(
|
||||||
|
editor.isActive("orderedList"),
|
||||||
|
() => editor.chain().focus().toggleOrderedList().run(),
|
||||||
|
<FormatListNumberedIcon fontSize="small" />,
|
||||||
|
"Numbered list",
|
||||||
|
)}
|
||||||
|
{btn(
|
||||||
|
editor.isActive("link"),
|
||||||
|
() => {
|
||||||
|
if (editor.isActive("link")) {
|
||||||
|
editor.chain().focus().unsetLink().run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = window.prompt("Link URL (https://…)");
|
||||||
|
if (url) editor.chain().focus().setLink({ href: url }).run();
|
||||||
|
},
|
||||||
|
<LinkIcon fontSize="small" />,
|
||||||
|
"Link",
|
||||||
|
)}
|
||||||
|
<Divider flexItem orientation="vertical" sx={{ mx: 0.5 }} />
|
||||||
|
{btn(false, () => editor.chain().focus().undo().run(), <UndoIcon fontSize="small" />, "Undo")}
|
||||||
|
{btn(false, () => editor.chain().focus().redo().run(), <RedoIcon fontSize="small" />, "Redo")}
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
minHeight,
|
||||||
|
"& .ProseMirror": { outline: "none", minHeight: minHeight - 20 },
|
||||||
|
"& .ProseMirror p": { m: 0, mb: 0.5 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/components/RichTextView.tsx
Normal file
16
frontend/src/components/RichTextView.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
/** Renders server-sanitized rich text (the API cleans all HTML with nh3
|
||||||
|
* before storing it, so this content is trusted). */
|
||||||
|
export function RichTextView({ html }: { html: string }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
"& p": { mt: 0, mb: 0.75 },
|
||||||
|
"& ul, & ol": { mt: 0, pl: 3 },
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/src/components/StageChip.tsx
Normal file
21
frontend/src/components/StageChip.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Chip } from "@mui/material";
|
||||||
|
import type { StageValue } from "../api/types";
|
||||||
|
import { STAGE_LABELS } from "../api/types";
|
||||||
|
import { STAGE_COLORS } from "../theme";
|
||||||
|
|
||||||
|
export function StageChip({
|
||||||
|
stage,
|
||||||
|
size = "small",
|
||||||
|
}: {
|
||||||
|
stage: StageValue;
|
||||||
|
size?: "small" | "medium";
|
||||||
|
}) {
|
||||||
|
const colors = STAGE_COLORS[stage] ?? { bg: "#eee", fg: "#333" };
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={STAGE_LABELS[stage] ?? stage}
|
||||||
|
size={size}
|
||||||
|
sx={{ bgcolor: colors.bg, color: colors.fg, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/StageStepper.tsx
Normal file
30
frontend/src/components/StageStepper.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Step, StepLabel, Stepper, useMediaQuery, useTheme } from "@mui/material";
|
||||||
|
import type { NcrDetail } from "../api/types";
|
||||||
|
import { STAGE_LABELS, STAGE_ORDER } from "../api/types";
|
||||||
|
|
||||||
|
/** Visual progress through the workflow. Secondary Disposition is only shown
|
||||||
|
* when that route was taken. */
|
||||||
|
export function StageStepper({ ncr }: { ncr: NcrDetail }) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
|
const stages = STAGE_ORDER.filter(
|
||||||
|
(s) => s !== "secondary_disposition" || ncr.secondary_review_needed,
|
||||||
|
);
|
||||||
|
const activeIndex = stages.indexOf(ncr.stage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stepper
|
||||||
|
activeStep={ncr.stage === "closed" ? stages.length : activeIndex}
|
||||||
|
alternativeLabel={!isSmall}
|
||||||
|
orientation={isSmall ? "vertical" : "horizontal"}
|
||||||
|
sx={{ my: 1 }}
|
||||||
|
>
|
||||||
|
{stages.map((s) => (
|
||||||
|
<Step key={s} completed={stages.indexOf(s) < activeIndex || ncr.stage === "closed"}>
|
||||||
|
<StepLabel>{STAGE_LABELS[s]}</StepLabel>
|
||||||
|
</Step>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
frontend/src/components/Toast.tsx
Normal file
74
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Alert, Snackbar, Stack } from "@mui/material";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { createContext, useCallback, useContext, useState } from "react";
|
||||||
|
|
||||||
|
type Severity = "success" | "info" | "warning" | "error";
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
severity: Severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toast: (message: string, severity?: Severity) => void;
|
||||||
|
warnings: (messages: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue>({
|
||||||
|
toast: () => {},
|
||||||
|
warnings: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useToast(): ToastContextValue {
|
||||||
|
return useContext(ToastContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const toast = useCallback((message: string, severity: Severity = "success") => {
|
||||||
|
setToasts((prev) => [...prev, { id: nextId++, message, severity }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const warnings = useCallback(
|
||||||
|
(messages: string[]) => {
|
||||||
|
for (const m of messages) toast(m, "warning");
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismiss = (id: number) =>
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toast, warnings }}>
|
||||||
|
{children}
|
||||||
|
<Stack
|
||||||
|
spacing={1}
|
||||||
|
sx={{ position: "fixed", bottom: 16, left: 16, zIndex: 2000, maxWidth: 420 }}
|
||||||
|
>
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<Snackbar
|
||||||
|
key={t.id}
|
||||||
|
open
|
||||||
|
autoHideDuration={t.severity === "warning" ? 10000 : 4000}
|
||||||
|
onClose={() => dismiss(t.id)}
|
||||||
|
sx={{ position: "static", transform: "none" }}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity={t.severity}
|
||||||
|
onClose={() => dismiss(t.id)}
|
||||||
|
variant="filled"
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
{t.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/UserPicker.tsx
Normal file
46
frontend/src/components/UserPicker.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Autocomplete, TextField } from "@mui/material";
|
||||||
|
import { useUsersByRole } from "../api/hooks";
|
||||||
|
import type { UserOut } from "../api/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
role: string;
|
||||||
|
label: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
value: UserOut[] | UserOut | null;
|
||||||
|
onChange: (value: UserOut[] | UserOut | null) => void;
|
||||||
|
helperText?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picker over users holding a given in-app role (drives the Disposition
|
||||||
|
* Authority dropdown and "Notify These People"). */
|
||||||
|
export function UserPicker({
|
||||||
|
role,
|
||||||
|
label,
|
||||||
|
multiple = false,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
helperText,
|
||||||
|
required,
|
||||||
|
}: Props) {
|
||||||
|
const users = useUsersByRole(role);
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
multiple={multiple}
|
||||||
|
options={users.data ?? []}
|
||||||
|
loading={users.isLoading}
|
||||||
|
value={value as never}
|
||||||
|
onChange={(_, v) => onChange(v as never)}
|
||||||
|
getOptionLabel={(u: UserOut) => u.display_name}
|
||||||
|
isOptionEqualToValue={(a: UserOut, b: UserOut) => a.id === b.id}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={label}
|
||||||
|
helperText={helperText}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/config.ts
Normal file
22
frontend/src/config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export interface AppConfig {
|
||||||
|
authMode: "entra" | "dev";
|
||||||
|
tenantId: string;
|
||||||
|
clientId: string;
|
||||||
|
apiScope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__APP_CONFIG__?: Partial<AppConfig>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = window.__APP_CONFIG__ ?? {};
|
||||||
|
|
||||||
|
export const config: AppConfig = {
|
||||||
|
authMode: w.authMode === "entra" ? "entra" : "dev",
|
||||||
|
tenantId: w.tenantId ?? "",
|
||||||
|
clientId: w.clientId ?? "",
|
||||||
|
apiScope:
|
||||||
|
w.apiScope || (w.clientId ? `api://${w.clientId}/access_as_user` : ""),
|
||||||
|
};
|
||||||
48
frontend/src/main.tsx
Normal file
48
frontend/src/main.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { CssBaseline, ThemeProvider } from "@mui/material";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import { msalInstance } from "./api/client";
|
||||||
|
import { AuthGate } from "./auth/AuthGate";
|
||||||
|
import { ToastProvider } from "./components/Toast";
|
||||||
|
import { theme } from "./theme";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: 1, refetchOnWindowFocus: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
if (msalInstance) {
|
||||||
|
await msalInstance.initialize();
|
||||||
|
const result = await msalInstance.handleRedirectPromise();
|
||||||
|
if (result?.account) {
|
||||||
|
msalInstance.setActiveAccount(result.account);
|
||||||
|
} else {
|
||||||
|
const accounts = msalInstance.getAllAccounts();
|
||||||
|
if (accounts.length > 0) msalInstance.setActiveAccount(accounts[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<AuthGate>
|
||||||
|
<App />
|
||||||
|
</AuthGate>
|
||||||
|
</ToastProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
186
frontend/src/pages/DashboardPage.tsx
Normal file
186
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { buildQuery, openBlob } from "../api/client";
|
||||||
|
import { useMe, useQueue, useUsersByRole } from "../api/hooks";
|
||||||
|
import type { QueueFilters } from "../api/types";
|
||||||
|
import { QueueTable } from "../components/QueueTable";
|
||||||
|
import { useToast } from "../components/Toast";
|
||||||
|
|
||||||
|
interface QueueDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
visible: (roles: string[]) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUEUES: QueueDef[] = [
|
||||||
|
{ key: "my_requests", label: "My Requests", visible: () => true },
|
||||||
|
{
|
||||||
|
key: "new_requests",
|
||||||
|
label: "New Requests",
|
||||||
|
visible: (r) => r.includes("disposition_authority") || r.includes("admin"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "secondary",
|
||||||
|
label: "My Secondary Queue",
|
||||||
|
visible: (r) =>
|
||||||
|
r.includes("secondary_disposition_authority") || r.includes("admin"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "operations",
|
||||||
|
label: "Operations",
|
||||||
|
visible: (r) => r.includes("operations") || r.includes("admin"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inspection",
|
||||||
|
label: "QC Inspection",
|
||||||
|
visible: (r) => r.includes("qc_inspector") || r.includes("admin"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "costing",
|
||||||
|
label: "Awaiting Costing",
|
||||||
|
visible: (r) => r.includes("costing") || r.includes("admin"),
|
||||||
|
},
|
||||||
|
{ key: "recently_closed", label: "Recently Closed", visible: () => true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const me = useMe();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const roles = useMemo(() => me.data?.roles ?? [], [me.data]);
|
||||||
|
const queues = useMemo(() => QUEUES.filter((q) => q.visible(roles)), [roles]);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [jobFilter, setJobFilter] = useState("");
|
||||||
|
const [authorityFilter, setAuthorityFilter] = useState<number | "">("");
|
||||||
|
|
||||||
|
const active = queues[Math.min(tab, queues.length - 1)];
|
||||||
|
const isNewRequests = active?.key === "new_requests";
|
||||||
|
const authorities = useUsersByRole("disposition_authority");
|
||||||
|
|
||||||
|
const filters: QueueFilters = isNewRequests
|
||||||
|
? {
|
||||||
|
job_number: jobFilter || undefined,
|
||||||
|
disposition_authority_id: authorityFilter || undefined,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const queue = useQueue(active?.key ?? "my_requests", filters, page);
|
||||||
|
|
||||||
|
const exportCsv = () => {
|
||||||
|
void openBlob(
|
||||||
|
`/api/ncrs/export.csv${buildQuery({ queue: active.key, ...filters })}`,
|
||||||
|
`ncr-${active.key}.csv`,
|
||||||
|
"download",
|
||||||
|
).catch((e) => toast(e.message, "error"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ sm: "center" }}
|
||||||
|
spacing={1}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
<Typography variant="h5">Dashboard</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => navigate("/ncrs/new")}
|
||||||
|
>
|
||||||
|
New NCR
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Tabs
|
||||||
|
value={Math.min(tab, queues.length - 1)}
|
||||||
|
onChange={(_, v) => {
|
||||||
|
setTab(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||||
|
>
|
||||||
|
{queues.map((q) => (
|
||||||
|
<Tab key={q.key} label={q.label} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
<CardContent>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
spacing={1}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
alignItems={{ sm: "center" }}
|
||||||
|
>
|
||||||
|
{isNewRequests && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Filter by job number"
|
||||||
|
value={jobFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setJobFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
label="Disposition authority"
|
||||||
|
value={authorityFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAuthorityFilter(
|
||||||
|
e.target.value === "" ? "" : Number(e.target.value),
|
||||||
|
);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
sx={{ minWidth: 220 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{(authorities.data ?? []).map((u) => (
|
||||||
|
<MenuItem key={u.id} value={u.id}>
|
||||||
|
{u.display_name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
<Button startIcon={<DownloadIcon />} onClick={exportCsv}>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<QueueTable
|
||||||
|
items={queue.data?.items ?? []}
|
||||||
|
total={queue.data?.total ?? 0}
|
||||||
|
page={page}
|
||||||
|
pageSize={25}
|
||||||
|
onPageChange={setPage}
|
||||||
|
loading={queue.isLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
421
frontend/src/pages/NcrDetailPage.tsx
Normal file
421
frontend/src/pages/NcrDetailPage.tsx
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import HistoryIcon from "@mui/icons-material/History";
|
||||||
|
import LockIcon from "@mui/icons-material/Lock";
|
||||||
|
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||||
|
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
Stack,
|
||||||
|
Tab,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { openBlob } from "../api/client";
|
||||||
|
import { useMe, useNcr, useNcrAudit } from "../api/hooks";
|
||||||
|
import type { NcrDetail } from "../api/types";
|
||||||
|
import { STAGE_LABELS } from "../api/types";
|
||||||
|
import { AttachmentSection } from "../components/AttachmentSection";
|
||||||
|
import { FieldRow } from "../components/FieldRow";
|
||||||
|
import { RichTextView } from "../components/RichTextView";
|
||||||
|
import { StageChip } from "../components/StageChip";
|
||||||
|
import { StageStepper } from "../components/StageStepper";
|
||||||
|
import { useToast } from "../components/Toast";
|
||||||
|
import {
|
||||||
|
CostingForm,
|
||||||
|
InitialDispositionForm,
|
||||||
|
InspectionForm,
|
||||||
|
OperationsForm,
|
||||||
|
ReopenDialog,
|
||||||
|
SecondaryDispositionForm,
|
||||||
|
} from "./StageForms";
|
||||||
|
|
||||||
|
function fmt(iso: string | null): string {
|
||||||
|
return iso ? new Date(iso).toLocaleString() : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(v: string | null): string {
|
||||||
|
return v === null
|
||||||
|
? "—"
|
||||||
|
: Number(v).toLocaleString(undefined, { style: "currency", currency: "USD" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({
|
||||||
|
title,
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ mb: 2 }}>
|
||||||
|
<CardHeader title={title} action={action} titleTypographyProps={{ variant: "h6" }} />
|
||||||
|
<Divider />
|
||||||
|
<CardContent>{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditTab({ ncrId }: { ncrId: number }) {
|
||||||
|
const audit = useNcrAudit(ncrId, true);
|
||||||
|
if (audit.isLoading) return <CircularProgress sx={{ m: 2 }} />;
|
||||||
|
if (audit.isError)
|
||||||
|
return <Alert severity="error">{(audit.error as Error).message}</Alert>;
|
||||||
|
const items = audit.data?.items ?? [];
|
||||||
|
return (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>When</TableCell>
|
||||||
|
<TableCell>Who</TableCell>
|
||||||
|
<TableCell>Action</TableCell>
|
||||||
|
<TableCell>Field</TableCell>
|
||||||
|
<TableCell>Before</TableCell>
|
||||||
|
<TableCell>After</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((a) => (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell sx={{ whiteSpace: "nowrap" }}>{fmt(a.created_at)}</TableCell>
|
||||||
|
<TableCell>{a.user.display_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={a.action.replace(/_/g, " ")} />
|
||||||
|
{a.detail && (
|
||||||
|
<Typography variant="caption" display="block" color="text.secondary">
|
||||||
|
{a.detail}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{a.field_name ?? ""}</TableCell>
|
||||||
|
<TableCell sx={{ maxWidth: 220, overflowWrap: "anywhere" }}>
|
||||||
|
{a.old_value ?? ""}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ maxWidth: 220, overflowWrap: "anywhere" }}>
|
||||||
|
{a.new_value ?? ""}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailBody({ ncr }: { ncr: NcrDetail }) {
|
||||||
|
const actions = ncr.available_actions;
|
||||||
|
const closed = ncr.stage === "closed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{closed && (
|
||||||
|
<Alert icon={<LockIcon />} severity="info" sx={{ mb: 2 }}>
|
||||||
|
This NCR is closed and locked. Closed on {fmt(ncr.closed_at)} by{" "}
|
||||||
|
{ncr.closed_by?.display_name}. Only an Admin can reopen it.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionCard title="Request">
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<FieldRow label="Job Number">{ncr.job_number}</FieldRow>
|
||||||
|
<FieldRow label="Date">{new Date(ncr.created_at).toLocaleDateString()}</FieldRow>
|
||||||
|
<FieldRow label="Department">{ncr.department}</FieldRow>
|
||||||
|
<FieldRow label="Deviation Category">{ncr.deviation_category}</FieldRow>
|
||||||
|
<FieldRow label="Requester">{ncr.requester.display_name}</FieldRow>
|
||||||
|
<FieldRow label="Disposition Authority">
|
||||||
|
{ncr.disposition_authority.display_name}
|
||||||
|
</FieldRow>
|
||||||
|
{ncr.job_info && (
|
||||||
|
<>
|
||||||
|
<FieldRow label="Part (ERP)">
|
||||||
|
{[ncr.job_info.part_id, ncr.job_info.part_description]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" — ")}
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow label="Customer (ERP)">{ncr.job_info.customer_name}</FieldRow>
|
||||||
|
<FieldRow label="WO Status (ERP)">{ncr.job_info.work_order_status}</FieldRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Deviation Detail
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ whiteSpace: "pre-wrap" }}>{ncr.deviation_detail}</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Attachments
|
||||||
|
</Typography>
|
||||||
|
<AttachmentSection
|
||||||
|
ncrId={ncr.id}
|
||||||
|
attachments={ncr.attachments}
|
||||||
|
canAdd={actions.includes("add_attachment")}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Disposition">
|
||||||
|
{ncr.stage === "new_request" && !actions.includes("initial_disposition") ? (
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Awaiting initial disposition by {ncr.disposition_authority.display_name}.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={2} sx={{ mb: 1 }}>
|
||||||
|
<FieldRow label="QC Authority">{ncr.qc_authority}</FieldRow>
|
||||||
|
<FieldRow label="Work Order">{ncr.work_order}</FieldRow>
|
||||||
|
<FieldRow label="Secondary Review">
|
||||||
|
{ncr.secondary_review_needed === null
|
||||||
|
? "—"
|
||||||
|
: ncr.secondary_review_needed
|
||||||
|
? `Yes — ${ncr.secondary_authorities.map((u) => u.display_name).join(", ") || "unassigned"}`
|
||||||
|
: "No"}
|
||||||
|
</FieldRow>
|
||||||
|
{ncr.disposition_notes && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Disposition Notes
|
||||||
|
</Typography>
|
||||||
|
<RichTextView html={ncr.disposition_notes} />
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
{actions.includes("initial_disposition") && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }}>
|
||||||
|
<Chip label="Initial Disposition — your action" color="primary" size="small" />
|
||||||
|
</Divider>
|
||||||
|
<InitialDispositionForm ncr={ncr} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{actions.includes("secondary_disposition") && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }}>
|
||||||
|
<Chip label="Secondary Disposition — your action" color="primary" size="small" />
|
||||||
|
</Divider>
|
||||||
|
<SecondaryDispositionForm ncr={ncr} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Operations">
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<FieldRow label="Operations Complete">
|
||||||
|
{ncr.operations_complete ? "Yes" : "Pending"}
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow label="Completed By">
|
||||||
|
{ncr.operations_completed_by?.display_name}
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow label="Completed At">{fmt(ncr.operations_completed_at)}</FieldRow>
|
||||||
|
</Grid>
|
||||||
|
{actions.includes("operations_complete") && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }}>
|
||||||
|
<Chip label="Operations — your action" color="primary" size="small" />
|
||||||
|
</Divider>
|
||||||
|
<OperationsForm ncr={ncr} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="QC Inspection">
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<FieldRow label="QC Approval">
|
||||||
|
{ncr.qc_approval === null ? "—" : ncr.qc_approval === "yes" ? "Yes" : "No"}
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow label="QC Closed">
|
||||||
|
{ncr.qc_closed
|
||||||
|
? `Yes — ${ncr.qc_closed_by?.display_name}, ${fmt(ncr.qc_closed_at)}`
|
||||||
|
: "Pending"}
|
||||||
|
</FieldRow>
|
||||||
|
{ncr.inspection_notes && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Inspection Notes
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ whiteSpace: "pre-wrap" }}>{ncr.inspection_notes}</Typography>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
{actions.includes("inspection") && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }}>
|
||||||
|
<Chip label="QC Inspection — your action" color="primary" size="small" />
|
||||||
|
</Divider>
|
||||||
|
<InspectionForm ncr={ncr} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Costing">
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<FieldRow label="Labor">{money(ncr.labor_cost)}</FieldRow>
|
||||||
|
<FieldRow label="Material">{money(ncr.material_cost)}</FieldRow>
|
||||||
|
<FieldRow label="Service">{money(ncr.service_cost)}</FieldRow>
|
||||||
|
<FieldRow label="Other">{money(ncr.other_cost)}</FieldRow>
|
||||||
|
<FieldRow label="Total">
|
||||||
|
<Typography component="span" fontWeight={700}>
|
||||||
|
{money(ncr.total_cost)}
|
||||||
|
</Typography>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow label="Costed By">
|
||||||
|
{ncr.costing_completed_by
|
||||||
|
? `${ncr.costing_completed_by.display_name}, ${fmt(ncr.costing_completed_at)}`
|
||||||
|
: null}
|
||||||
|
</FieldRow>
|
||||||
|
</Grid>
|
||||||
|
{actions.includes("costing") && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }}>
|
||||||
|
<Chip label="Costing — your action" color="primary" size="small" />
|
||||||
|
</Divider>
|
||||||
|
<CostingForm ncr={ncr} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Workflow History">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>When</TableCell>
|
||||||
|
<TableCell>Action</TableCell>
|
||||||
|
<TableCell>From</TableCell>
|
||||||
|
<TableCell>To</TableCell>
|
||||||
|
<TableCell>By</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{ncr.transitions.map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell sx={{ whiteSpace: "nowrap" }}>{fmt(t.acted_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{t.action.replace(/_/g, " ")}
|
||||||
|
{t.note && (
|
||||||
|
<Typography variant="caption" display="block" color="text.secondary">
|
||||||
|
{t.note}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{t.from_stage ? STAGE_LABELS[t.from_stage] : "—"}</TableCell>
|
||||||
|
<TableCell>{STAGE_LABELS[t.to_stage]}</TableCell>
|
||||||
|
<TableCell>{t.acted_by.display_name}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</SectionCard>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NcrDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const ncrId = Number(id);
|
||||||
|
const ncrQuery = useNcr(Number.isFinite(ncrId) ? ncrId : undefined);
|
||||||
|
const me = useMe();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [tab, setTab] = useState(0);
|
||||||
|
const [reopenOpen, setReopenOpen] = useState(false);
|
||||||
|
|
||||||
|
if (ncrQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ textAlign: "center", py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ncrQuery.isError || !ncrQuery.data) {
|
||||||
|
return (
|
||||||
|
<Alert severity="error">
|
||||||
|
{(ncrQuery.error as Error | undefined)?.message ?? "NCR not found."}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ncr = ncrQuery.data;
|
||||||
|
const canAudit =
|
||||||
|
ncr.available_actions.includes("view_audit") ||
|
||||||
|
(me.data?.roles.includes("admin") ?? false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 1100, mx: "auto" }}>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ md: "center" }}
|
||||||
|
spacing={1}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||||
|
<Typography variant="h5">{ncr.ncr_number}</Typography>
|
||||||
|
<StageChip stage={ncr.stage} size="medium" />
|
||||||
|
<Typography color="text.secondary" variant="body2">
|
||||||
|
{ncr.days_in_stage}d in stage
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
startIcon={<PictureAsPdfIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() =>
|
||||||
|
void openBlob(`/api/ncrs/${ncr.id}/pdf`, `${ncr.ncr_number}.pdf`, "open").catch(
|
||||||
|
(e) => toast(e.message, "error"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Print / Download PDF
|
||||||
|
</Button>
|
||||||
|
{ncr.available_actions.includes("reopen") && (
|
||||||
|
<Button
|
||||||
|
startIcon={<LockOpenIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
color="warning"
|
||||||
|
onClick={() => setReopenOpen(true)}
|
||||||
|
>
|
||||||
|
Reopen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<StageStepper ncr={ncr} />
|
||||||
|
|
||||||
|
{canAudit ? (
|
||||||
|
<>
|
||||||
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||||
|
<Tab label="Details" />
|
||||||
|
<Tab icon={<HistoryIcon />} iconPosition="start" label="Audit History" />
|
||||||
|
</Tabs>
|
||||||
|
{tab === 0 ? (
|
||||||
|
<DetailBody ncr={ncr} />
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<AuditTab ncrId={ncr.id} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<DetailBody ncr={ncr} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReopenDialog ncr={ncr} open={reopenOpen} onClose={() => setReopenOpen(false)} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
frontend/src/pages/NewNcrPage.tsx
Normal file
193
frontend/src/pages/NewNcrPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CircularProgress,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useLookups } from "../api/hooks";
|
||||||
|
import type { NcrDetail, NcrMutationOut, UserOut } from "../api/types";
|
||||||
|
import { AttachmentSection } from "../components/AttachmentSection";
|
||||||
|
import { JobNumberField } from "../components/JobNumberField";
|
||||||
|
import { useToast } from "../components/Toast";
|
||||||
|
import { UserPicker } from "../components/UserPicker";
|
||||||
|
|
||||||
|
export function NewNcrPage() {
|
||||||
|
const lookups = useLookups();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { warnings } = useToast();
|
||||||
|
|
||||||
|
const [jobNumber, setJobNumber] = useState("");
|
||||||
|
const [departmentId, setDepartmentId] = useState<number | "">("");
|
||||||
|
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||||
|
const [authority, setAuthority] = useState<UserOut | null>(null);
|
||||||
|
const [detail, setDetail] = useState("");
|
||||||
|
const [created, setCreated] = useState<NcrDetail | null>(null);
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api<NcrMutationOut>("/api/ncrs", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
job_number: jobNumber.trim(),
|
||||||
|
department_id: departmentId,
|
||||||
|
deviation_category_id: categoryId,
|
||||||
|
disposition_authority_id: authority?.id,
|
||||||
|
deviation_detail: detail.trim(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setCreated(data.ncr);
|
||||||
|
if (data.warnings.length) warnings(data.warnings);
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const valid =
|
||||||
|
jobNumber.trim().length > 0 &&
|
||||||
|
departmentId !== "" &&
|
||||||
|
categoryId !== "" &&
|
||||||
|
authority !== null &&
|
||||||
|
detail.trim().length >= 5;
|
||||||
|
|
||||||
|
// ── Confirmation screen: prominent NCR number + immediate photo upload ────
|
||||||
|
if (created) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 720, mx: "auto" }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: "center", py: 4 }}>
|
||||||
|
<CheckCircleIcon color="success" sx={{ fontSize: 56 }} />
|
||||||
|
<Typography variant="h6" sx={{ mt: 1 }}>
|
||||||
|
NCR submitted
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="h3"
|
||||||
|
color="primary"
|
||||||
|
sx={{ fontWeight: 800, my: 1, letterSpacing: 1 }}
|
||||||
|
>
|
||||||
|
{created.ncr_number}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Job {created.job_number} · {created.department} ·{" "}
|
||||||
|
{created.deviation_category}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary" variant="body2" sx={{ mt: 0.5 }}>
|
||||||
|
{created.disposition_authority.display_name} has been notified for
|
||||||
|
initial disposition.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: "left", mt: 3 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Add photos / files now (optional)
|
||||||
|
</Typography>
|
||||||
|
<AttachmentSection
|
||||||
|
ncrId={created.id}
|
||||||
|
attachments={created.attachments}
|
||||||
|
canAdd
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 3 }}>
|
||||||
|
<Button variant="contained" onClick={() => navigate(`/ncrs/${created.id}`)}>
|
||||||
|
View NCR
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setCreated(null);
|
||||||
|
setJobNumber("");
|
||||||
|
setDetail("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Submit another
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 720, mx: "auto" }}>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
New NCR Request
|
||||||
|
</Typography>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{create.isError && (
|
||||||
|
<Alert severity="error">{(create.error as Error).message}</Alert>
|
||||||
|
)}
|
||||||
|
<JobNumberField value={jobNumber} onChange={setJobNumber} required />
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
required
|
||||||
|
label="Department"
|
||||||
|
value={departmentId}
|
||||||
|
onChange={(e) => setDepartmentId(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{(lookups.data?.departments ?? []).map((d) => (
|
||||||
|
<MenuItem key={d.id} value={d.id}>
|
||||||
|
{d.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
required
|
||||||
|
label="Deviation Category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{(lookups.data?.deviation_categories ?? []).map((c) => (
|
||||||
|
<MenuItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<UserPicker
|
||||||
|
role="disposition_authority"
|
||||||
|
label="Disposition Authority"
|
||||||
|
value={authority}
|
||||||
|
onChange={(v) => setAuthority(v as UserOut | null)}
|
||||||
|
required
|
||||||
|
helperText="Who should review this nonconformance?"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Deviation Detail"
|
||||||
|
value={detail}
|
||||||
|
onChange={(e) => setDetail(e.target.value)}
|
||||||
|
required
|
||||||
|
multiline
|
||||||
|
minRows={4}
|
||||||
|
helperText="Describe what was found, where, and how many pieces are affected."
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Photos and file attachments can be added on the next screen, right
|
||||||
|
after the NCR number is assigned.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
disabled={!valid || create.isPending}
|
||||||
|
onClick={() => create.mutate()}
|
||||||
|
startIcon={create.isPending ? <CircularProgress size={18} /> : undefined}
|
||||||
|
>
|
||||||
|
Submit NCR
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
414
frontend/src/pages/ReportsPage.tsx
Normal file
414
frontend/src/pages/ReportsPage.tsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import TableChartIcon from "@mui/icons-material/TableChart";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CircularProgress,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { useLookups, useReportsSummary } from "../api/hooks";
|
||||||
|
|
||||||
|
/* Validated categorical palette (dataviz reference instance, light mode).
|
||||||
|
* Fixed slot order — color follows the entity: labor=1 material=2 service=3
|
||||||
|
* other=4. Aqua/yellow sit below 3:1 on white, so the cost chart ships a
|
||||||
|
* table view (relief rule). */
|
||||||
|
const SERIES = {
|
||||||
|
labor: "#2a78d6",
|
||||||
|
material: "#1baf7a",
|
||||||
|
service: "#eda100",
|
||||||
|
other: "#008300",
|
||||||
|
};
|
||||||
|
const SINGLE_HUE = "#2a78d6";
|
||||||
|
const GRID = "#eceff1";
|
||||||
|
const TICK = { fill: "#52514e", fontSize: 12 };
|
||||||
|
|
||||||
|
function money(n: number): string {
|
||||||
|
return n.toLocaleString(undefined, {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvDownload(filename: string, rows: Record<string, unknown>[]): void {
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
const headers = Object.keys(rows[0]);
|
||||||
|
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
|
||||||
|
const csv = [
|
||||||
|
headers.join(","),
|
||||||
|
...rows.map((r) => headers.map((h) => esc(r[h])).join(",")),
|
||||||
|
].join("\n");
|
||||||
|
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ flexGrow: 1, minWidth: 150 }}>
|
||||||
|
<CardContent sx={{ py: 1.5, "&:last-child": { pb: 1.5 } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5">{value}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartCard({
|
||||||
|
title,
|
||||||
|
subheader,
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subheader?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ height: "100%" }}>
|
||||||
|
<CardHeader
|
||||||
|
title={title}
|
||||||
|
subheader={subheader}
|
||||||
|
action={action}
|
||||||
|
titleTypographyProps={{ variant: "subtitle1", fontWeight: 700 }}
|
||||||
|
subheaderTypographyProps={{ variant: "caption" }}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<CardContent>{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportsPage() {
|
||||||
|
const lookups = useLookups();
|
||||||
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
const [departmentId, setDepartmentId] = useState<number | "">("");
|
||||||
|
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||||
|
const [costAsTable, setCostAsTable] = useState(false);
|
||||||
|
|
||||||
|
const summary = useReportsSummary({
|
||||||
|
date_from: dateFrom || undefined,
|
||||||
|
date_to: dateTo || undefined,
|
||||||
|
department_id: departmentId || undefined,
|
||||||
|
category_id: categoryId || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = summary.data;
|
||||||
|
const costRows = (data?.cost_over_time ?? []).map((c) => ({
|
||||||
|
month: c.month,
|
||||||
|
Labor: Number(c.labor),
|
||||||
|
Material: Number(c.material),
|
||||||
|
Service: Number(c.service),
|
||||||
|
Other: Number(c.other),
|
||||||
|
Total: Number(c.total),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Reports
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Filters — one row above the charts */}
|
||||||
|
<Card sx={{ mb: 2 }}>
|
||||||
|
<CardContent sx={{ py: 1.5, "&:last-child": { pb: 1.5 } }}>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="date"
|
||||||
|
label="From"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="date"
|
||||||
|
label="To"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
label="Department"
|
||||||
|
value={departmentId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDepartmentId(e.target.value === "" ? "" : Number(e.target.value))
|
||||||
|
}
|
||||||
|
sx={{ minWidth: 180 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All departments</MenuItem>
|
||||||
|
{(lookups.data?.departments ?? []).map((d) => (
|
||||||
|
<MenuItem key={d.id} value={d.id}>
|
||||||
|
{d.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
label="Category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCategoryId(e.target.value === "" ? "" : Number(e.target.value))
|
||||||
|
}
|
||||||
|
sx={{ minWidth: 180 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All categories</MenuItem>
|
||||||
|
{(lookups.data?.deviation_categories ?? []).map((c) => (
|
||||||
|
<MenuItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{summary.isLoading || !data ? (
|
||||||
|
<Box sx={{ textAlign: "center", py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ mb: 2 }} flexWrap="wrap" useFlexGap>
|
||||||
|
<StatTile label="Total NCRs" value={String(data.total_ncrs)} />
|
||||||
|
<StatTile label="Open" value={String(data.open_ncrs)} />
|
||||||
|
<StatTile label="Closed" value={String(data.closed_ncrs)} />
|
||||||
|
<StatTile label="Cost of Nonconformance" value={money(Number(data.total_cost))} />
|
||||||
|
<StatTile
|
||||||
|
label="Avg End-to-End"
|
||||||
|
value={
|
||||||
|
data.end_to_end_avg_days !== null
|
||||||
|
? `${data.end_to_end_avg_days} days`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<ChartCard title="NCRs by Month">
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={data.by_month}>
|
||||||
|
<CartesianGrid stroke={GRID} vertical={false} />
|
||||||
|
<XAxis dataKey="month" tick={TICK} tickLine={false} />
|
||||||
|
<YAxis allowDecimals={false} tick={TICK} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="count" name="NCRs" fill={SINGLE_HUE} radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<ChartCard
|
||||||
|
title="Cost of Nonconformance Over Time"
|
||||||
|
subheader="Closed NCRs, by month closed"
|
||||||
|
action={
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
<ToggleButton
|
||||||
|
value="table"
|
||||||
|
size="small"
|
||||||
|
selected={costAsTable}
|
||||||
|
onChange={() => setCostAsTable(!costAsTable)}
|
||||||
|
title="Toggle table view"
|
||||||
|
>
|
||||||
|
<TableChartIcon fontSize="small" />
|
||||||
|
</ToggleButton>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={() => csvDownload("cost-of-nonconformance.csv", costRows)}
|
||||||
|
>
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{costAsTable ? (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Month</TableCell>
|
||||||
|
<TableCell align="right">Labor</TableCell>
|
||||||
|
<TableCell align="right">Material</TableCell>
|
||||||
|
<TableCell align="right">Service</TableCell>
|
||||||
|
<TableCell align="right">Other</TableCell>
|
||||||
|
<TableCell align="right">Total</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{costRows.map((r) => (
|
||||||
|
<TableRow key={r.month}>
|
||||||
|
<TableCell>{r.month}</TableCell>
|
||||||
|
<TableCell align="right">{money(r.Labor)}</TableCell>
|
||||||
|
<TableCell align="right">{money(r.Material)}</TableCell>
|
||||||
|
<TableCell align="right">{money(r.Service)}</TableCell>
|
||||||
|
<TableCell align="right">{money(r.Other)}</TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontWeight: 700 }}>
|
||||||
|
{money(r.Total)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={costRows}>
|
||||||
|
<CartesianGrid stroke={GRID} vertical={false} />
|
||||||
|
<XAxis dataKey="month" tick={TICK} tickLine={false} />
|
||||||
|
<YAxis
|
||||||
|
tick={TICK}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(v: number) => money(v)}
|
||||||
|
width={72}
|
||||||
|
/>
|
||||||
|
<Tooltip formatter={(v: number) => money(v)} />
|
||||||
|
<Legend />
|
||||||
|
{/* 2px surface gap between stacked segments via stroke */}
|
||||||
|
<Bar dataKey="Labor" stackId="cost" fill={SERIES.labor} stroke="#fff" strokeWidth={1} />
|
||||||
|
<Bar dataKey="Material" stackId="cost" fill={SERIES.material} stroke="#fff" strokeWidth={1} />
|
||||||
|
<Bar dataKey="Service" stackId="cost" fill={SERIES.service} stroke="#fff" strokeWidth={1} />
|
||||||
|
<Bar dataKey="Other" stackId="cost" fill={SERIES.other} stroke="#fff" strokeWidth={1} radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<ChartCard title="NCRs by Department">
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(200, data.by_department.length * 34)}>
|
||||||
|
<BarChart data={data.by_department} layout="vertical">
|
||||||
|
<CartesianGrid stroke={GRID} horizontal={false} />
|
||||||
|
<XAxis type="number" allowDecimals={false} tick={TICK} tickLine={false} />
|
||||||
|
<YAxis type="category" dataKey="name" width={130} tick={TICK} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="count" name="NCRs" fill={SINGLE_HUE} radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<ChartCard title="NCRs by Deviation Category">
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(200, data.by_category.length * 34)}>
|
||||||
|
<BarChart data={data.by_category} layout="vertical">
|
||||||
|
<CartesianGrid stroke={GRID} horizontal={false} />
|
||||||
|
<XAxis type="number" allowDecimals={false} tick={TICK} tickLine={false} />
|
||||||
|
<YAxis type="category" dataKey="name" width={160} tick={TICK} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="count" name="NCRs" fill={SINGLE_HUE} radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<ChartCard title="Open NCR Aging" subheader="Days in current stage">
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={data.aging}>
|
||||||
|
<CartesianGrid stroke={GRID} vertical={false} />
|
||||||
|
<XAxis dataKey="bucket" tick={TICK} tickLine={false} />
|
||||||
|
<YAxis allowDecimals={false} tick={TICK} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="count" name="Open NCRs" fill={SINGLE_HUE} radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<ChartCard title="Average Cycle Time per Stage" subheader="Days spent in each stage">
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={data.cycle_times} layout="vertical">
|
||||||
|
<CartesianGrid stroke={GRID} horizontal={false} />
|
||||||
|
<XAxis type="number" tick={TICK} tickLine={false} />
|
||||||
|
<YAxis type="category" dataKey="stage_label" width={150} tick={TICK} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip formatter={(v: number) => `${v} days`} />
|
||||||
|
<Bar dataKey="avg_days" name="Avg days" fill={SINGLE_HUE} radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<ChartCard
|
||||||
|
title="Top Job Numbers by NCR Count"
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={() => csvDownload("top-jobs.csv", data.top_jobs)}
|
||||||
|
>
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Job Number</TableCell>
|
||||||
|
<TableCell align="right">NCRs</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{data.top_jobs.map((j) => (
|
||||||
|
<TableRow key={j.job_number}>
|
||||||
|
<TableCell>{j.job_number}</TableCell>
|
||||||
|
<TableCell align="right">{j.count}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{data.top_jobs.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2}>
|
||||||
|
<Typography color="text.secondary">No data.</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
frontend/src/pages/SearchPage.tsx
Normal file
172
frontend/src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Grid,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { buildQuery, openBlob } from "../api/client";
|
||||||
|
import { useLookups, useQueue } from "../api/hooks";
|
||||||
|
import type { QueueFilters } from "../api/types";
|
||||||
|
import { STAGE_LABELS, STAGE_ORDER } from "../api/types";
|
||||||
|
import { QueueTable } from "../components/QueueTable";
|
||||||
|
import { useToast } from "../components/Toast";
|
||||||
|
|
||||||
|
export function SearchPage() {
|
||||||
|
const lookups = useLookups();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [departmentId, setDepartmentId] = useState<number | "">("");
|
||||||
|
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||||
|
const [stage, setStage] = useState("");
|
||||||
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
|
||||||
|
const filters: QueueFilters = {
|
||||||
|
q: q || undefined,
|
||||||
|
department_id: departmentId || undefined,
|
||||||
|
category_id: categoryId || undefined,
|
||||||
|
stage: stage || undefined,
|
||||||
|
date_from: dateFrom || undefined,
|
||||||
|
date_to: dateTo || undefined,
|
||||||
|
};
|
||||||
|
const results = useQueue("all", filters, page);
|
||||||
|
|
||||||
|
const set = <T,>(setter: (v: T) => void) => (v: T) => {
|
||||||
|
setter(v);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Search NCRs
|
||||||
|
</Typography>
|
||||||
|
<Card sx={{ mb: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Grid container spacing={1.5}>
|
||||||
|
<Grid item xs={12} sm={4} md={3}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="NCR # or Job #"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => set(setQ)(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={4} md={2}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
label="Department"
|
||||||
|
value={departmentId}
|
||||||
|
onChange={(e) =>
|
||||||
|
set(setDepartmentId)(e.target.value === "" ? "" : Number(e.target.value))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{(lookups.data?.departments ?? []).map((d) => (
|
||||||
|
<MenuItem key={d.id} value={d.id}>
|
||||||
|
{d.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={4} md={2}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
label="Category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) =>
|
||||||
|
set(setCategoryId)(e.target.value === "" ? "" : Number(e.target.value))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{(lookups.data?.deviation_categories ?? []).map((c) => (
|
||||||
|
<MenuItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={4} md={2}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
select
|
||||||
|
label="Stage"
|
||||||
|
value={stage}
|
||||||
|
onChange={(e) => set(setStage)(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All</MenuItem>
|
||||||
|
{STAGE_ORDER.map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>
|
||||||
|
{STAGE_LABELS[s]}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={4} md={1.5}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
type="date"
|
||||||
|
label="From"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => set(setDateFrom)(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={4} md={1.5}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
type="date"
|
||||||
|
label="To"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => set(setDateTo)(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
void openBlob(
|
||||||
|
`/api/ncrs/export.csv${buildQuery({ queue: "all", ...filters })}`,
|
||||||
|
"ncr-search.csv",
|
||||||
|
"download",
|
||||||
|
).catch((e) => toast(e.message, "error"))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<QueueTable
|
||||||
|
items={results.data?.items ?? []}
|
||||||
|
total={results.data?.total ?? 0}
|
||||||
|
page={page}
|
||||||
|
pageSize={25}
|
||||||
|
onPageChange={setPage}
|
||||||
|
loading={results.isLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
455
frontend/src/pages/StageForms.tsx
Normal file
455
frontend/src/pages/StageForms.tsx
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
/** Editable forms for the current workflow stage. Visibility is driven by the
|
||||||
|
* server's `available_actions`; the API re-enforces role + stage on submit. */
|
||||||
|
import SendIcon from "@mui/icons-material/Send";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
|
InputAdornment,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useNcrMutation } from "../api/hooks";
|
||||||
|
import type { NcrDetail, NcrMutationOut, UserOut } from "../api/types";
|
||||||
|
import { STAGE_LABELS } from "../api/types";
|
||||||
|
import { RichTextEditor } from "../components/RichTextEditor";
|
||||||
|
import { useToast } from "../components/Toast";
|
||||||
|
import { UserPicker } from "../components/UserPicker";
|
||||||
|
|
||||||
|
function useWarnToast() {
|
||||||
|
const { warnings, toast } = useToast();
|
||||||
|
return { warnings, toast };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initial Disposition ──────────────────────────────────────────────────────
|
||||||
|
export function InitialDispositionForm({ ncr }: { ncr: NcrDetail }) {
|
||||||
|
const { warnings, toast } = useWarnToast();
|
||||||
|
const [qcAuthority, setQcAuthority] = useState(ncr.qc_authority ?? "");
|
||||||
|
const [workOrder, setWorkOrder] = useState(ncr.work_order ?? "");
|
||||||
|
const [notes, setNotes] = useState(ncr.disposition_notes ?? "");
|
||||||
|
const [secondary, setSecondary] = useState(false);
|
||||||
|
const [assignees, setAssignees] = useState<UserOut[]>([]);
|
||||||
|
|
||||||
|
const mutation = useNcrMutation(
|
||||||
|
() =>
|
||||||
|
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/initial-disposition`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
qc_authority: qcAuthority || null,
|
||||||
|
work_order: workOrder || null,
|
||||||
|
disposition_notes: notes || null,
|
||||||
|
secondary_review_needed: secondary,
|
||||||
|
secondary_authority_ids: assignees.map((u) => u.id),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
warnings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{mutation.isError && (
|
||||||
|
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
label="QC Authority"
|
||||||
|
value={qcAuthority}
|
||||||
|
onChange={(e) => setQcAuthority(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Work Order"
|
||||||
|
value={workOrder}
|
||||||
|
onChange={(e) => setWorkOrder(e.target.value)}
|
||||||
|
/>
|
||||||
|
<RichTextEditor label="Disposition Notes" value={notes} onChange={setNotes} />
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox checked={secondary} onChange={(e) => setSecondary(e.target.checked)} />
|
||||||
|
}
|
||||||
|
label="Secondary review needed"
|
||||||
|
/>
|
||||||
|
{secondary && (
|
||||||
|
<UserPicker
|
||||||
|
role="secondary_disposition_authority"
|
||||||
|
label="Notify These People"
|
||||||
|
multiple
|
||||||
|
value={assignees}
|
||||||
|
onChange={(v) => setAssignees((v as UserOut[]) ?? [])}
|
||||||
|
helperText="The selected secondary disposition authorities will see this NCR in their personal queue."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||||
|
disabled={mutation.isPending || (secondary && assignees.length === 0)}
|
||||||
|
onClick={() =>
|
||||||
|
mutation.mutate(undefined as never, {
|
||||||
|
onSuccess: () =>
|
||||||
|
toast(
|
||||||
|
secondary
|
||||||
|
? "Sent for secondary disposition review."
|
||||||
|
: "Released to Operations.",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{secondary ? "Send for Secondary Review" : "Release to Operations"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Secondary Disposition ────────────────────────────────────────────────────
|
||||||
|
export function SecondaryDispositionForm({ ncr }: { ncr: NcrDetail }) {
|
||||||
|
const { warnings, toast } = useWarnToast();
|
||||||
|
const [qcAuthority, setQcAuthority] = useState(ncr.qc_authority ?? "");
|
||||||
|
const [workOrder, setWorkOrder] = useState(ncr.work_order ?? "");
|
||||||
|
const [notes, setNotes] = useState(ncr.disposition_notes ?? "");
|
||||||
|
|
||||||
|
const mutation = useNcrMutation(
|
||||||
|
(release: boolean) =>
|
||||||
|
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/secondary-disposition`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
qc_authority: qcAuthority || null,
|
||||||
|
work_order: workOrder || null,
|
||||||
|
disposition_notes: notes || null,
|
||||||
|
release,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
warnings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{mutation.isError && (
|
||||||
|
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
label="QC Authority"
|
||||||
|
value={qcAuthority}
|
||||||
|
onChange={(e) => setQcAuthority(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Work Order"
|
||||||
|
value={workOrder}
|
||||||
|
onChange={(e) => setWorkOrder(e.target.value)}
|
||||||
|
/>
|
||||||
|
<RichTextEditor label="Disposition Notes" value={notes} onChange={setNotes} />
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
mutation.mutate(false, { onSuccess: () => toast("Saved.") })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
mutation.mutate(true, {
|
||||||
|
onSuccess: () => toast("Released to Operations."),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Release to Operations
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Operations ───────────────────────────────────────────────────────────────
|
||||||
|
export function OperationsForm({ ncr }: { ncr: NcrDetail }) {
|
||||||
|
const { warnings, toast } = useWarnToast();
|
||||||
|
const mutation = useNcrMutation(
|
||||||
|
() =>
|
||||||
|
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/operations-complete`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
warnings,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{mutation.isError && (
|
||||||
|
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||||
|
)}
|
||||||
|
<Typography color="text.secondary" variant="body2">
|
||||||
|
Review the disposition above, complete the rework/repair, then mark
|
||||||
|
operations complete to send this NCR to QC Inspection.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
mutation.mutate(undefined as never, {
|
||||||
|
onSuccess: () => toast("Operations complete — sent to QC Inspection."),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Mark Operations Complete
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QC Inspection ────────────────────────────────────────────────────────────
|
||||||
|
export function InspectionForm({ ncr }: { ncr: NcrDetail }) {
|
||||||
|
const { warnings, toast } = useWarnToast();
|
||||||
|
const [approval, setApproval] = useState<"yes" | "no" | null>(ncr.qc_approval);
|
||||||
|
const [notes, setNotes] = useState(ncr.inspection_notes ?? "");
|
||||||
|
const [close, setClose] = useState(false);
|
||||||
|
|
||||||
|
const mutation = useNcrMutation(
|
||||||
|
() =>
|
||||||
|
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/inspection`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
qc_approval: approval,
|
||||||
|
inspection_notes: notes || null,
|
||||||
|
qc_closed: close,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
warnings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{mutation.isError && (
|
||||||
|
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||||
|
)}
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Typography>QC Approval:</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
value={approval}
|
||||||
|
onChange={(_, v) => setApproval(v)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ToggleButton value="yes" color="success">
|
||||||
|
Yes
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="no" color="error">
|
||||||
|
No
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
label="Inspection Notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={close} onChange={(e) => setClose(e.target.checked)} />}
|
||||||
|
label="QC Closed — send to Costing (inspection can no longer be edited)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
mutation.mutate(undefined as never, {
|
||||||
|
onSuccess: () =>
|
||||||
|
toast(close ? "QC closed — sent to Costing." : "Inspection saved."),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{close ? "Save & Close QC" : "Save Inspection"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Costing ──────────────────────────────────────────────────────────────────
|
||||||
|
function CostField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (/^\d*\.?\d{0,2}$/.test(v)) onChange(v);
|
||||||
|
}}
|
||||||
|
inputProps={{ inputMode: "decimal" }}
|
||||||
|
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||||
|
sx={{ maxWidth: 220 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CostingForm({ ncr }: { ncr: NcrDetail }) {
|
||||||
|
const { warnings, toast } = useWarnToast();
|
||||||
|
const [labor, setLabor] = useState(ncr.labor_cost ?? "");
|
||||||
|
const [material, setMaterial] = useState(ncr.material_cost ?? "");
|
||||||
|
const [service, setService] = useState(ncr.service_cost ?? "");
|
||||||
|
const [other, setOther] = useState(ncr.other_cost ?? "");
|
||||||
|
|
||||||
|
const total = useMemo(() => {
|
||||||
|
const sum =
|
||||||
|
(parseFloat(labor) || 0) +
|
||||||
|
(parseFloat(material) || 0) +
|
||||||
|
(parseFloat(service) || 0) +
|
||||||
|
(parseFloat(other) || 0);
|
||||||
|
return sum.toLocaleString(undefined, {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
});
|
||||||
|
}, [labor, material, service, other]);
|
||||||
|
|
||||||
|
const allSet = [labor, material, service, other].every((v) => v !== "");
|
||||||
|
|
||||||
|
const mutation = useNcrMutation(
|
||||||
|
() =>
|
||||||
|
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/costing`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
labor_cost: labor || "0",
|
||||||
|
material_cost: material || "0",
|
||||||
|
service_cost: service || "0",
|
||||||
|
other_cost: other || "0",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
warnings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{mutation.isError && (
|
||||||
|
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||||
|
)}
|
||||||
|
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||||
|
<CostField label="Labor Cost" value={labor} onChange={setLabor} />
|
||||||
|
<CostField label="Material Cost" value={material} onChange={setMaterial} />
|
||||||
|
<CostField label="Service Cost" value={service} onChange={setService} />
|
||||||
|
<CostField label="Other Cost" value={other} onChange={setOther} />
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="h6">Total: {total}</Typography>
|
||||||
|
<Alert severity="info">
|
||||||
|
Saving costs completes the workflow and closes this NCR. A closed NCR is
|
||||||
|
locked; only an Admin can reopen it.
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!allSet || mutation.isPending}
|
||||||
|
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||||
|
onClick={() =>
|
||||||
|
mutation.mutate(undefined as never, {
|
||||||
|
onSuccess: () => toast("Costing complete — NCR closed."),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save Costs & Close NCR
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin Reopen ─────────────────────────────────────────────────────────────
|
||||||
|
const REOPEN_TARGETS = [
|
||||||
|
"new_request",
|
||||||
|
"secondary_disposition",
|
||||||
|
"operations",
|
||||||
|
"qc_inspection",
|
||||||
|
"costing",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ReopenDialog({
|
||||||
|
ncr,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
ncr: NcrDetail;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { warnings, toast } = useWarnToast();
|
||||||
|
const [target, setTarget] = useState<string>("costing");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
const mutation = useNcrMutation(
|
||||||
|
() =>
|
||||||
|
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/reopen`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { to_stage: target, reason: reason.trim() },
|
||||||
|
}),
|
||||||
|
warnings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>Reopen {ncr.ncr_number}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
{mutation.isError && (
|
||||||
|
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Reopen to stage"
|
||||||
|
value={target}
|
||||||
|
onChange={(e) => setTarget(e.target.value)}
|
||||||
|
>
|
||||||
|
{REOPEN_TARGETS.map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>
|
||||||
|
{STAGE_LABELS[s]}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="Reason (required, recorded in the audit trail)"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="warning"
|
||||||
|
disabled={reason.trim().length < 5 || mutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
mutation.mutate(undefined as never, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast("NCR reopened.");
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reopen NCR
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
frontend/src/pages/admin/AdminAuditPage.tsx
Normal file
105
frontend/src/pages/admin/AdminAuditPage.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api, buildQuery } from "../../api/client";
|
||||||
|
import type { AuditEntry } from "../../api/types";
|
||||||
|
|
||||||
|
interface GlobalAudit {
|
||||||
|
items: AuditEntry[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminAuditPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [ncrNumber, setNcrNumber] = useState("");
|
||||||
|
|
||||||
|
const audit = useQuery({
|
||||||
|
queryKey: ["admin-audit", page, ncrNumber],
|
||||||
|
queryFn: () =>
|
||||||
|
api<GlobalAudit>(
|
||||||
|
`/api/admin/audit${buildQuery({ page, page_size: 50, ncr_number: ncrNumber })}`,
|
||||||
|
),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Filter by NCR number"
|
||||||
|
value={ncrNumber}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNcrNumber(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Immutable system-wide audit trail (field-level before/after values).
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>When</TableCell>
|
||||||
|
<TableCell>Who</TableCell>
|
||||||
|
<TableCell>Action</TableCell>
|
||||||
|
<TableCell>Field</TableCell>
|
||||||
|
<TableCell>Before</TableCell>
|
||||||
|
<TableCell>After</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{(audit.data?.items ?? []).map((a) => (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell sx={{ whiteSpace: "nowrap" }}>
|
||||||
|
{new Date(a.created_at).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{a.user.display_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={a.action.replace(/_/g, " ")} />
|
||||||
|
{a.detail && (
|
||||||
|
<Typography variant="caption" display="block" color="text.secondary">
|
||||||
|
{a.detail}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{a.field_name ?? ""}</TableCell>
|
||||||
|
<TableCell sx={{ maxWidth: 200, overflowWrap: "anywhere" }}>
|
||||||
|
{a.old_value ?? ""}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ maxWidth: 200, overflowWrap: "anywhere" }}>
|
||||||
|
{a.new_value ?? ""}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={audit.data?.total ?? 0}
|
||||||
|
page={page - 1}
|
||||||
|
onPageChange={(_, p) => setPage(p + 1)}
|
||||||
|
rowsPerPage={50}
|
||||||
|
rowsPerPageOptions={[50]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user