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