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:
ang3l12
2026-07-13 11:41:22 -06:00
commit dea316b113
111 changed files with 13817 additions and 0 deletions

12
.claude/launch.json Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
__pycache__
*.pyc
.venv
venv
.pytest_cache
tests
dev.db
attachments
.env

31
backend/Dockerfile Normal file
View 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
View 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
View 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()

View 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"}

View 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)

View 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
View File

View File

173
backend/app/auth/deps.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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",
]

View 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"

View 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")

View 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")

View 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

View 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
View 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")

View 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")

View File

View 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,
)

View 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"}

View 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,
}

View 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
View 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"'
},
)

View 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, "07 days"), (8, 14, "814 days"), (15, 30, "1530 days"),
(31, 60, "3160 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],
)

View 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
]

View File

View 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)

View 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
View 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

View 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]

View 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
View 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())

View File

View 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

View 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", [])

View 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

View 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}"
]

View 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

View 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))

View 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

View 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

View 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",
)

View 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
View 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
View File

@@ -0,0 +1,6 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
filterwarnings = [
"ignore::DeprecationWarning:jose.*",
]

View File

@@ -0,0 +1,4 @@
-r requirements.txt
pytest>=8.2
pytest-asyncio>=0.24
aiosqlite>=0.20

16
backend/requirements.txt Normal file
View 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

View File

89
backend/tests/conftest.py Normal file
View 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"]),
}

View 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

View 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))

View 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

View 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
View 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"]

View 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
View 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
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
*.tsbuildinfo

13
frontend/Dockerfile Normal file
View 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

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View 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"
}
}

View 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
View 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
View 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
View 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
View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 }}
/>
);
}

View 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 }}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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();

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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