commit dea316b1132201b19d7fccda6276a8dea22a783b Author: ang3l12 Date: Mon Jul 13 11:41:22 2026 -0600 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 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..cd16534 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "frontend-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "frontend", + "port": 5173 + } + ] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..937649c --- /dev/null +++ b/.env.example @@ -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:// and the bare client id). +ENTRA_API_AUDIENCE= + +# Scope the frontend requests for the API. Leave empty for the default +# api:///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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12fc029 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7cdc33 --- /dev/null +++ b/README.md @@ -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://` → *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 + `: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`. | diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..b52a2da --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +__pycache__ +*.pyc +.venv +venv +.pytest_cache +tests +dev.db +attachments +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..56b0e2c --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..c22496d --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..4a21870 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..3217cf0 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/0001_initial_schema.py b/backend/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..0124e40 --- /dev/null +++ b/backend/alembic/versions/0001_initial_schema.py @@ -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) diff --git a/backend/alembic/versions/0002_reporting_views.py b/backend/alembic/versions/0002_reporting_views.py new file mode 100644 index 0000000..cfb2fb8 --- /dev/null +++ b/backend/alembic/versions/0002_reporting_views.py @@ -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") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py new file mode 100644 index 0000000..6597c1f --- /dev/null +++ b/backend/app/auth/deps.py @@ -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) diff --git a/backend/app/auth/entra.py b/backend/app/auth/entra.py new file mode 100644 index 0000000..6dae0be --- /dev/null +++ b/backend/app/auth/entra.py @@ -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 + ) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..913572b --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..b0a669f --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/dev_init.py b/backend/app/dev_init.py new file mode 100644 index 0000000..bd7fefd --- /dev/null +++ b/backend/app/dev_init.py @@ -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()) diff --git a/backend/app/domain.py b/backend/app/domain.py new file mode 100644 index 0000000..fca99a1 --- /dev/null +++ b/backend/app/domain.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2683338 --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..22a5b60 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/app_setting.py b/backend/app/models/app_setting.py new file mode 100644 index 0000000..ab8b821 --- /dev/null +++ b/backend/app/models/app_setting.py @@ -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" diff --git a/backend/app/models/attachment.py b/backend/app/models/attachment.py new file mode 100644 index 0000000..39d0682 --- /dev/null +++ b/backend/app/models/attachment.py @@ -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: "/" + 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") diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..7375700 --- /dev/null +++ b/backend/app/models/audit.py @@ -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") diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..c5bd0da --- /dev/null +++ b/backend/app/models/base.py @@ -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 diff --git a/backend/app/models/lookups.py b/backend/app/models/lookups.py new file mode 100644 index 0000000..ab02e28 --- /dev/null +++ b/backend/app/models/lookups.py @@ -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) diff --git a/backend/app/models/ncr.py b/backend/app/models/ncr.py new file mode 100644 index 0000000..5e0ae3d --- /dev/null +++ b/backend/app/models/ncr.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..79a7680 --- /dev/null +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..2e330d2 --- /dev/null +++ b/backend/app/routers/admin.py @@ -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, + ) diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..990eff1 --- /dev/null +++ b/backend/app/routers/health.py @@ -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"} diff --git a/backend/app/routers/jobs.py b/backend/app/routers/jobs.py new file mode 100644 index 0000000..62a1bd5 --- /dev/null +++ b/backend/app/routers/jobs.py @@ -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, + } diff --git a/backend/app/routers/lookups.py b/backend/app/routers/lookups.py new file mode 100644 index 0000000..427835f --- /dev/null +++ b/backend/app/routers/lookups.py @@ -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], + ) diff --git a/backend/app/routers/ncrs.py b/backend/app/routers/ncrs.py new file mode 100644 index 0000000..b05c09b --- /dev/null +++ b/backend/app/routers/ncrs.py @@ -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"' + }, + ) diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py new file mode 100644 index 0000000..aa575cc --- /dev/null +++ b/backend/app/routers/reports.py @@ -0,0 +1,185 @@ +"""Built-in reports: counts, cost of nonconformance, aging, cycle times, +top jobs. All queries respect the shared date-range/department/category +filters.""" +from collections import defaultdict +from decimal import Decimal + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.deps import CurrentUser, get_current_user +from app.database import get_db +from app.domain import STAGE_LABELS, Stage +from app.models import Department, DeviationCategory, Ncr, StageTransition +from app.models.base import utcnow +from app.schemas.report import ( + AgingBucket, + CostByMonth, + CountByMonth, + CountByName, + ReportsSummaryOut, + StageCycleTime, + TopJob, +) + +router = APIRouter(tags=["reports"]) + +_AGING_BUCKETS = [(0, 7, "0–7 days"), (8, 14, "8–14 days"), (15, 30, "15–30 days"), + (31, 60, "31–60 days"), (61, None, "60+ days")] + + +def _base_filters(stmt, date_from, date_to, department_id, category_id): + if date_from: + stmt = stmt.where(Ncr.created_at >= date_from) + if date_to: + stmt = stmt.where(Ncr.created_at <= f"{date_to} 23:59:59") + if department_id: + stmt = stmt.where(Ncr.department_id == department_id) + if category_id: + stmt = stmt.where(Ncr.deviation_category_id == category_id) + return stmt + + +@router.get("/reports/summary", response_model=ReportsSummaryOut) +async def reports_summary( + date_from: str | None = Query(default=None, description="YYYY-MM-DD"), + date_to: str | None = Query(default=None, description="YYYY-MM-DD"), + department_id: int | None = None, + category_id: int | None = None, + _: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ReportsSummaryOut: + filters = dict( + date_from=date_from, + date_to=date_to, + department_id=department_id, + category_id=category_id, + ) + + # Load the filtered NCR set once; aggregate in Python. NCR volume is a few + # thousand rows a year, so this stays cheap and keeps the SQL portable. + ncrs = ( + (await db.execute(_base_filters(select(Ncr), **filters))).scalars().unique().all() + ) + + dept_names = { + d.id: d.name for d in (await db.execute(select(Department))).scalars().all() + } + cat_names = { + c.id: c.name + for c in (await db.execute(select(DeviationCategory))).scalars().all() + } + + by_dept: dict[str, int] = defaultdict(int) + by_cat: dict[str, int] = defaultdict(int) + by_month: dict[str, int] = defaultdict(int) + cost_by_month: dict[str, dict[str, Decimal]] = defaultdict( + lambda: {"labor": Decimal(0), "material": Decimal(0), "service": Decimal(0), "other": Decimal(0)} + ) + aging_counts: dict[str, int] = {label: 0 for _, _, label in _AGING_BUCKETS} + job_counts: dict[str, int] = defaultdict(int) + total_cost = Decimal(0) + open_count = 0 + closed_count = 0 + now = utcnow() + + for n in ncrs: + by_dept[dept_names.get(n.department_id, "?")] += 1 + by_cat[cat_names.get(n.deviation_category_id, "?")] += 1 + by_month[n.created_at.strftime("%Y-%m")] += 1 + job_counts[n.job_number] += 1 + if n.stage == Stage.CLOSED.value: + closed_count += 1 + month = (n.closed_at or n.created_at).strftime("%Y-%m") + bucket = cost_by_month[month] + bucket["labor"] += n.labor_cost or 0 + bucket["material"] += n.material_cost or 0 + bucket["service"] += n.service_cost or 0 + bucket["other"] += n.other_cost or 0 + total_cost += n.total_cost or 0 + else: + open_count += 1 + days = max(0, (now - n.stage_entered_at).days) + for lo, hi, label in _AGING_BUCKETS: + if days >= lo and (hi is None or days <= hi): + aging_counts[label] += 1 + break + + # ── cycle times from the transition history ───────────────────────────── + ncr_ids = [n.id for n in ncrs] + stage_durations: dict[str, list[float]] = defaultdict(list) + end_to_end: list[float] = [] + if ncr_ids: + transitions = ( + ( + await db.execute( + select(StageTransition) + .where(StageTransition.ncr_id.in_(ncr_ids)) + .order_by(StageTransition.ncr_id, StageTransition.acted_at) + ) + ) + .scalars() + .all() + ) + per_ncr: dict[int, list[StageTransition]] = defaultdict(list) + for t in transitions: + per_ncr[t.ncr_id].append(t) + for items in per_ncr.values(): + for prev, nxt in zip(items, items[1:]): + delta_days = (nxt.acted_at - prev.acted_at).total_seconds() / 86400 + stage_durations[prev.to_stage].append(delta_days) + first, last = items[0], items[-1] + if last.to_stage == Stage.CLOSED.value: + end_to_end.append( + (last.acted_at - first.acted_at).total_seconds() / 86400 + ) + + cycle_times = [ + StageCycleTime( + stage=s.value, + stage_label=STAGE_LABELS[s], + avg_days=round(sum(v) / len(v), 2), + samples=len(v), + ) + for s in Stage + if s != Stage.CLOSED and (v := stage_durations.get(s.value)) + ] + + months = sorted(set(by_month) | set(cost_by_month)) + return ReportsSummaryOut( + total_ncrs=len(ncrs), + open_ncrs=open_count, + closed_ncrs=closed_count, + total_cost=total_cost, + by_department=sorted( + (CountByName(name=k, count=v) for k, v in by_dept.items()), + key=lambda x: -x.count, + ), + by_category=sorted( + (CountByName(name=k, count=v) for k, v in by_cat.items()), + key=lambda x: -x.count, + ), + by_month=[CountByMonth(month=m, count=by_month.get(m, 0)) for m in months], + cost_over_time=[ + CostByMonth( + month=m, + labor=c["labor"], + material=c["material"], + service=c["service"], + other=c["other"], + total=c["labor"] + c["material"] + c["service"] + c["other"], + ) + for m in months + if (c := cost_by_month.get(m)) + ], + aging=[AgingBucket(bucket=label, count=aging_counts[label]) for _, _, label in _AGING_BUCKETS], + cycle_times=cycle_times, + end_to_end_avg_days=( + round(sum(end_to_end) / len(end_to_end), 2) if end_to_end else None + ), + top_jobs=sorted( + (TopJob(job_number=j, count=c) for j, c in job_counts.items()), + key=lambda x: -x.count, + )[:10], + ) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..0dbb4ee --- /dev/null +++ b/backend/app/routers/users.py @@ -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 + ] diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..f58eecb --- /dev/null +++ b/backend/app/schemas/common.py @@ -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) diff --git a/backend/app/schemas/lookup.py b/backend/app/schemas/lookup.py new file mode 100644 index 0000000..58bcc7e --- /dev/null +++ b/backend/app/schemas/lookup.py @@ -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] diff --git a/backend/app/schemas/ncr.py b/backend/app/schemas/ncr.py new file mode 100644 index 0000000..47d2205 --- /dev/null +++ b/backend/app/schemas/ncr.py @@ -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 diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py new file mode 100644 index 0000000..90f2ea2 --- /dev/null +++ b/backend/app/schemas/report.py @@ -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] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..1e02ec1 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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)) diff --git a/backend/app/seed.py b/backend/app/seed.py new file mode 100644 index 0000000..b364e0c --- /dev/null +++ b/backend/app/seed.py @@ -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 = ( + "

Disposition: Rework per attached instructions. " + "Re-inspect all affected features.

" + ) + 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()) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..73d3e55 --- /dev/null +++ b/backend/app/services/audit.py @@ -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 diff --git a/backend/app/services/graph.py b/backend/app/services/graph.py new file mode 100644 index 0000000..8f45f2a --- /dev/null +++ b/backend/app/services/graph.py @@ -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", []) diff --git a/backend/app/services/job_lookup.py b/backend/app/services/job_lookup.py new file mode 100644 index 0000000..56a59c7 --- /dev/null +++ b/backend/app/services/job_lookup.py @@ -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 diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py new file mode 100644 index 0000000..42378d6 --- /dev/null +++ b/backend/app/services/notifications.py @@ -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"{e(k)}" + f"{e(v or '')}" + for k, v in rows + ) + return f""" +
+

{e(_EVENT_SUBJECT[event])}

+

{e(summary)}

+ {table}
+

+ Open {e(ncr.ncr_number)} +

+

Sent automatically by the PESCO NCR system.

+
+ """ + + +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}" + ] diff --git a/backend/app/services/numbering.py b/backend/app/services/numbering.py new file mode 100644 index 0000000..31b33fb --- /dev/null +++ b/backend/app/services/numbering.py @@ -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 diff --git a/backend/app/services/pdf.py b/backend/app/services/pdf.py new file mode 100644 index 0000000..e58645c --- /dev/null +++ b/backend/app/services/pdf.py @@ -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)) diff --git a/backend/app/services/sanitize.py b/backend/app/services/sanitize.py new file mode 100644 index 0000000..d063b07 --- /dev/null +++ b/backend/app/services/sanitize.py @@ -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 diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..6cecdc2 --- /dev/null +++ b/backend/app/services/storage.py @@ -0,0 +1,87 @@ +"""Attachment storage on the local filesystem (a named Docker volume in +production). Files live at ATTACHMENTS_DIR//; 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 diff --git a/backend/app/services/workflow.py b/backend/app/services/workflow.py new file mode 100644 index 0000000..b2fc9fd --- /dev/null +++ b/backend/app/services/workflow.py @@ -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", + ) diff --git a/backend/app/templates/ncr_pdf.html b/backend/app/templates/ncr_pdf.html new file mode 100644 index 0000000..460be5a --- /dev/null +++ b/backend/app/templates/ncr_pdf.html @@ -0,0 +1,204 @@ + + + + + + + + +
+
+
PESCO
+

Non-Conformance Report

+
+
+
{{ ncr.ncr_number }}
+
Stage: {{ stage_label }}
+
Generated {{ generated_at.strftime("%Y-%m-%d %H:%M") }} UTC
+
+
+ +

Request

+ + + + + + + + + + + + + + + + + + {% if ncr.job_info %} + + + + + + {% endif %} +
NCR Number{{ ncr.ncr_number }}Date{{ ncr.created_at.strftime("%Y-%m-%d") }}
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 }}Work Order{{ ncr.work_order or "—" }}
Part (ERP){{ ncr.job_info.part_id or "—" }} {{ ncr.job_info.part_description or "" }}Customer (ERP){{ ncr.job_info.customer_name or "—" }}
+
{{ ncr.deviation_detail }}
+ +

Disposition

+ + + + + + +
QC Authority{{ ncr.qc_authority or "—" }}Secondary Review + {% 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 %} +
+{% if ncr.disposition_notes %} +
{{ ncr.disposition_notes | safe }}
+{% else %} +
No disposition notes recorded.
+{% endif %} + +

Operations

+ + + + + + + +
Operations Complete{% if ncr.operations_complete %}Yes{% else %}Pending{% endif %}Completed By / At + {% if ncr.operations_completed_by %} + {{ ncr.operations_completed_by.display_name }} — + {{ ncr.operations_completed_at.strftime("%Y-%m-%d %H:%M") }} UTC + {% else %}—{% endif %} +
+ +

QC Inspection

+ + + + + + + +
QC Approval{{ ncr.qc_approval | capitalize if ncr.qc_approval else "—" }}QC Closed + {% if ncr.qc_closed %}Yes — {{ ncr.qc_closed_by.display_name }}, + {{ ncr.qc_closed_at.strftime("%Y-%m-%d %H:%M") }} UTC + {% else %}Pending{% endif %} +
+{% if ncr.inspection_notes %} +
{{ ncr.inspection_notes }}
+{% endif %} + +

Costing

+ + + + + + + + + + + + + + + + + +
Labor{% if ncr.labor_cost is not none %}${{ "%.2f" | format(ncr.labor_cost) }}{% else %}—{% endif %}Material{% if ncr.material_cost is not none %}${{ "%.2f" | format(ncr.material_cost) }}{% else %}—{% endif %}
Service{% if ncr.service_cost is not none %}${{ "%.2f" | format(ncr.service_cost) }}{% else %}—{% endif %}Other{% if ncr.other_cost is not none %}${{ "%.2f" | format(ncr.other_cost) }}{% else %}—{% endif %}
Total Cost of Nonconformance + {% if ncr.total_cost is not none %}${{ "%.2f" | format(ncr.total_cost) }}{% else %}—{% endif %} +
+{% if ncr.closed_at %} +

Closed by {{ ncr.closed_by.display_name }} on {{ ncr.closed_at.strftime("%Y-%m-%d %H:%M") }} UTC.

+{% endif %} + +{% if images or other_files %} +

Attachments ({{ images | length + other_files | length }})

+{% if images %} +
+ {% for img in images %} +
+ {{ img.filename }} +
{{ img.filename }} — {{ img.uploaded_by }}, + {{ img.uploaded_at.strftime("%Y-%m-%d") }}
+
+ {% endfor %} +
+{% endif %} +{% if other_files %} + + + {% for f in other_files %} + + + + + {% endfor %} +
FileUploaded ByDateSize
{{ f.filename }}{{ f.uploaded_by }}{{ f.uploaded_at.strftime("%Y-%m-%d %H:%M") }}{{ f.size_kb }} KB
+{% endif %} +{% endif %} + +

Workflow History

+ + + {% for t in ncr.transitions %} + + + + + + + + + {% endfor %} +
Date (UTC)ActionFromToByNote
{{ t.acted_at.strftime("%Y-%m-%d %H:%M") }}{{ t.action.replace("_", " ") | title }}{{ stage_labels[Stage(t.from_stage)] if t.from_stage else "—" }}{{ stage_labels[Stage(t.to_stage)] }}{{ t.acted_by.display_name }}{{ t.note or "" }}
+ + + diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..026a555 --- /dev/null +++ b/backend/entrypoint.sh @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..36ced35 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,6 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +filterwarnings = [ + "ignore::DeprecationWarning:jose.*", +] diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..ad6b6d2 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=8.2 +pytest-asyncio>=0.24 +aiosqlite>=0.20 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..325e81c --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..4bd9c69 --- /dev/null +++ b/backend/tests/conftest.py @@ -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"]), + } diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py new file mode 100644 index 0000000..4d5568b --- /dev/null +++ b/backend/tests/test_attachments.py @@ -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 diff --git a/backend/tests/test_numbering.py b/backend/tests/test_numbering.py new file mode 100644 index 0000000..4760c94 --- /dev/null +++ b/backend/tests/test_numbering.py @@ -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)) diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py new file mode 100644 index 0000000..eced2b2 --- /dev/null +++ b/backend/tests/test_permissions.py @@ -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 diff --git a/backend/tests/test_state_machine.py b/backend/tests/test_state_machine.py new file mode 100644 index 0000000..1765025 --- /dev/null +++ b/backend/tests/test_state_machine.py @@ -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": "

Updated by secondary.

", "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='

Keep

link', + ) + notes = r.json()["ncr"]["disposition_notes"] + assert " 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 = "

Rework per instructions.

", +): + 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"] diff --git a/db/init/01-powerbi-user.sh b/db/init/01-powerbi-user.sh new file mode 100644 index 0000000..e226175 --- /dev/null +++ b/db/init/01-powerbi-user.sh @@ -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}" </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 diff --git a/frontend/docker-entrypoint.d/50-config.sh b/frontend/docker-entrypoint.d/50-config.sh new file mode 100644 index 0000000..7fddcc2 --- /dev/null +++ b/frontend/docker-entrypoint.d/50-config.sh @@ -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 < + + + + + + PESCO NCR + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..0a25296 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..91c2515 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3671 @@ +{ + "name": "pesco-ncr-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pesco-ncr-frontend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@azure/msal-browser": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.30.0.tgz", + "integrity": "sha512-I0XlIGVdM4E9kYP5eTjgW8fgATdzwxJvQ6bm2PNiHaZhEuUz47NYw1xHthC9R+lXz4i9zbShS0VdLyxd7n0GGA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-2.2.0.tgz", + "integrity": "sha512-2V+9JXeXyyjYNF92y5u0tU4el9px/V1+vkRuN+DtoxyiMHCtYQpJoaFdGWArh43zhz5aqQqiGW/iajPDSu3QsQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@azure/msal-browser": "^3.27.0", + "react": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.17.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/types": "~7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tiptap/core": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", + "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz", + "integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz", + "integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz", + "integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz", + "integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz", + "integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", + "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", + "integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz", + "integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz", + "integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz", + "integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz", + "integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz", + "integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", + "integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz", + "integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", + "integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz", + "integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", + "integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz", + "integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz", + "integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz", + "integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", + "integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", + "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", + "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz", + "integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==", + "license": "MIT", + "dependencies": { + "@tiptap/extension-bubble-menu": "^2.27.2", + "@tiptap/extension-floating-menu": "^2.27.2", + "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3", + "use-sync-external-store": "^1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz", + "integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.27.2", + "@tiptap/extension-blockquote": "^2.27.2", + "@tiptap/extension-bold": "^2.27.2", + "@tiptap/extension-bullet-list": "^2.27.2", + "@tiptap/extension-code": "^2.27.2", + "@tiptap/extension-code-block": "^2.27.2", + "@tiptap/extension-document": "^2.27.2", + "@tiptap/extension-dropcursor": "^2.27.2", + "@tiptap/extension-gapcursor": "^2.27.2", + "@tiptap/extension-hard-break": "^2.27.2", + "@tiptap/extension-heading": "^2.27.2", + "@tiptap/extension-history": "^2.27.2", + "@tiptap/extension-horizontal-rule": "^2.27.2", + "@tiptap/extension-italic": "^2.27.2", + "@tiptap/extension-list-item": "^2.27.2", + "@tiptap/extension-ordered-list": "^2.27.2", + "@tiptap/extension-paragraph": "^2.27.2", + "@tiptap/extension-strike": "^2.27.2", + "@tiptap/extension-text": "^2.27.2", + "@tiptap/extension-text-style": "^2.27.2", + "@tiptap/pm": "^2.27.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.5.tgz", + "integrity": "sha512-ac8trNQ01ybKDRTcfUc56LZufG3oYyU4N25qSXgp8dS0U4JtzzCj7oQlKu5v09VSmS5IseYoQ2yDkTbo7f7D8Q==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz", + "integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.1.tgz", + "integrity": "sha512-rRqzZnRgkyh69XoOMrfFJHwauHscLBmHbq772kwbic1ymQAM8gXjzEbJse5j1ep2UO2HRIAQL0bY3kZ/RoqjVw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, + "node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..289ce0a --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/config.js b/frontend/public/config.js new file mode 100644 index 0000000..eb7ce00 --- /dev/null +++ b/frontend/public/config.js @@ -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: "", +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2dda320 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..1e1b360 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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> { + 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 { + 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( + path: string, + options: { method?: string; body?: unknown } = {}, +): Promise { + const headers: Record = 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(path: string, form: FormData): Promise { + 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 { + 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 { + 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 { + 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}` : ""; +} diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts new file mode 100644 index 0000000..d471f12 --- /dev/null +++ b/frontend/src/api/hooks.ts @@ -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("/api/me"), + staleTime: 5 * 60_000, + retry: 1, + }); +} + +export function useLookups() { + return useQuery({ + queryKey: ["lookups"], + queryFn: () => api("/api/lookups"), + staleTime: 5 * 60_000, + }); +} + +export function useUsersByRole(role: string) { + return useQuery({ + queryKey: ["users", role], + queryFn: () => api(`/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( + `/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(`/api/ncrs/${id}`), + enabled: id !== undefined, + }); +} + +export function useNcrAudit(id: number, enabled: boolean) { + return useQuery({ + queryKey: ["ncr-audit", id], + queryFn: () => api(`/api/ncrs/${id}/audit`), + enabled, + }); +} + +export function useJobLookup(jobNumber: string) { + return useQuery({ + queryKey: ["job-lookup", jobNumber], + queryFn: () => + api(`/api/jobs/${encodeURIComponent(jobNumber)}/lookup`), + enabled: jobNumber.trim().length > 2, + staleTime: 60_000, + }); +} + +export function useReportsSummary(filters: Record) { + return useQuery({ + queryKey: ["reports", filters], + queryFn: () => + api(`/api/reports/summary${buildQuery(filters)}`), + }); +} + +/** Shared invalidation + warning plumbing for every NCR mutation. */ +export function useNcrMutation( + mutationFn: (vars: TVars) => Promise, + 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(`/api/ncrs/${ncrId}/attachments`, form); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["ncr", ncrId] }); + qc.invalidateQueries({ queryKey: ["ncr-audit", ncrId] }); + }, + }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..6441a9a --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,246 @@ +export type StageValue = + | "new_request" + | "secondary_disposition" + | "operations" + | "qc_inspection" + | "costing" + | "closed"; + +export const STAGE_LABELS: Record = { + 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 = { + 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; +} diff --git a/frontend/src/auth/AuthGate.tsx b/frontend/src/auth/AuthGate.tsx new file mode 100644 index 0000000..1c6460a --- /dev/null +++ b/frontend/src/auth/AuthGate.tsx @@ -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 ( + + + {children} + + + ); +} + +/** 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 ( + + + Signing you in… + + ); + } + 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 ( + + PESCO NCR + + {denied ? "Access denied. " : ""} + {detail} + + {denied && ( + + Ask IT to add you to the NCR access group, then sign in again. + + )} + + + ); + } + 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 ( + + + + Redirecting to Microsoft sign-in… + + + ); + } + return {children}; +} + +export function AuthGate({ children }: { children: ReactNode }) { + if (config.authMode === "dev") { + return {children}; + } + if (!config.clientId || !config.tenantId) { + return ( + + PESCO NCR + + 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. + + + ); + } + return ( + + {children} + + ); +} diff --git a/frontend/src/components/AttachmentSection.tsx b/frontend/src/components/AttachmentSection.tsx new file mode 100644 index 0000000..788c092 --- /dev/null +++ b/frontend/src/components/AttachmentSection.tsx @@ -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 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 ( + + 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, + }} + > + + + {att.original_filename} + + + + ); + } + + return ( + + 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 ? ( + {att.original_filename} + ) : ( + + )} + + + ); +} + +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(null); + const cameraInput = useRef(null); + const [lightbox, setLightbox] = useState(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 ( + + + {attachments.map((att) => ( + + ))} + {attachments.length === 0 && ( + + No attachments yet. + + )} + + + {canAdd && ( + + + + {/* capture="environment" opens the rear camera on tablets/phones */} + handleFiles(e.target.files)} + /> + handleFiles(e.target.files)} + /> + + )} + + setLightbox(null)} maxWidth="lg"> + + {lightbox && ( + attachment + )} + + + + ); +} diff --git a/frontend/src/components/FieldRow.tsx b/frontend/src/components/FieldRow.tsx new file mode 100644 index 0000000..8c277c6 --- /dev/null +++ b/frontend/src/components/FieldRow.tsx @@ -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 ( + + + {label} + + {children || "—"} + + ); +} diff --git a/frontend/src/components/JobNumberField.tsx b/frontend/src/components/JobNumberField.tsx new file mode 100644 index 0000000..31251d0 --- /dev/null +++ b/frontend/src/components/JobNumberField.tsx @@ -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 ( + + onChange(e.target.value)} + required={required} + inputProps={{ maxLength: 100 }} + /> + {info?.found && ( + + {info.part_id && } + {info.customer_name && ( + + )} + {info.work_order_status && ( + + )} + + )} + + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..adc9221 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -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 ( + + + + ); +} + +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: , path: "/" }, + { label: "New NCR", icon: , path: "/ncrs/new" }, + { label: "Search", icon: , path: "/search" }, + { label: "Reports", icon: , path: "/reports" }, + ...(isAdmin + ? [{ label: "Admin", icon: , path: "/admin" }] + : []), + ]; + + const drawer = ( + + + + PESCO NCR + + + + + {nav.map((item) => { + const selected = + item.path === "/" + ? location.pathname === "/" + : location.pathname.startsWith(item.path); + return ( + { + navigate(item.path); + setMobileOpen(false); + }} + sx={{ minHeight: 48 }} + > + {item.icon} + + + ); + })} + + + ); + + return ( + + + + {!isDesktop && ( + setMobileOpen(true)} + sx={{ mr: 1 }} + > + + + )} + + Non-Conformance Reports + + {config.authMode === "dev" && } + {me.data && ( + + + {me.data.display_name + .split(" ") + .map((p) => p[0]) + .slice(0, 2) + .join("")} + + + )} + + + + {isDesktop ? ( + + {drawer} + + ) : ( + setMobileOpen(false)} + ModalProps={{ keepMounted: true }} + sx={{ "& .MuiDrawer-paper": { width: DRAWER_WIDTH } }} + > + {drawer} + + )} + + + {children} + + + ); +} diff --git a/frontend/src/components/QueueTable.tsx b/frontend/src/components/QueueTable.tsx new file mode 100644 index 0000000..cd71f0c --- /dev/null +++ b/frontend/src/components/QueueTable.tsx @@ -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 ( + + + + ); + } + if (items.length === 0) { + return ( + + No NCRs in this queue. + + ); + } + + const pagination = ( + onPageChange(p + 1)} + rowsPerPage={pageSize} + rowsPerPageOptions={[pageSize]} + /> + ); + + if (isSmall) { + return ( + + + {items.map((n) => ( + + navigate(`/ncrs/${n.id}`)}> + + + {n.ncr_number} + + + + Job {n.job_number} · {n.department} · {n.deviation_category} + + + {n.requester} · {fmtDate(n.created_at)} · {n.days_in_stage}d in + stage + + + + + ))} + + {pagination} + + ); + } + + return ( + + + + + + NCR # + Job # + Department + Requester + Category + Stage + Days in Stage + Created + + + + {items.map((n) => ( + navigate(`/ncrs/${n.id}`)} + > + {n.ncr_number} + {n.job_number} + {n.department} + {n.requester} + {n.deviation_category} + + + + {n.days_in_stage} + {fmtDate(n.created_at)} + + ))} + +
+
+ {pagination} +
+ ); +} diff --git a/frontend/src/components/RichTextEditor.tsx b/frontend/src/components/RichTextEditor.tsx new file mode 100644 index 0000000..4b4e60e --- /dev/null +++ b/frontend/src/components/RichTextEditor.tsx @@ -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, + ) => ( + { + e.preventDefault(); + onClick(); + }} + size="small" + sx={{ border: 0, px: 1 }} + title={title} + > + {icon} + + ); + + return ( + + {label && ( + + {label} + + )} + + + {btn( + editor.isActive("bold"), + () => editor.chain().focus().toggleBold().run(), + , + "Bold", + )} + {btn( + editor.isActive("italic"), + () => editor.chain().focus().toggleItalic().run(), + , + "Italic", + )} + {btn( + editor.isActive("strike"), + () => editor.chain().focus().toggleStrike().run(), + , + "Strikethrough", + )} + {btn( + editor.isActive("bulletList"), + () => editor.chain().focus().toggleBulletList().run(), + , + "Bullet list", + )} + {btn( + editor.isActive("orderedList"), + () => editor.chain().focus().toggleOrderedList().run(), + , + "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(); + }, + , + "Link", + )} + + {btn(false, () => editor.chain().focus().undo().run(), , "Undo")} + {btn(false, () => editor.chain().focus().redo().run(), , "Redo")} + + + + + + + + ); +} diff --git a/frontend/src/components/RichTextView.tsx b/frontend/src/components/RichTextView.tsx new file mode 100644 index 0000000..f182eb3 --- /dev/null +++ b/frontend/src/components/RichTextView.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/StageChip.tsx b/frontend/src/components/StageChip.tsx new file mode 100644 index 0000000..cd8550b --- /dev/null +++ b/frontend/src/components/StageChip.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/StageStepper.tsx b/frontend/src/components/StageStepper.tsx new file mode 100644 index 0000000..3851422 --- /dev/null +++ b/frontend/src/components/StageStepper.tsx @@ -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 ( + + {stages.map((s) => ( + + {STAGE_LABELS[s]} + + ))} + + ); +} diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..9fa63ad --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -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({ + toast: () => {}, + warnings: () => {}, +}); + +export function useToast(): ToastContextValue { + return useContext(ToastContext); +} + +let nextId = 1; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + 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 ( + + {children} + + {toasts.map((t) => ( + dismiss(t.id)} + sx={{ position: "static", transform: "none" }} + > + dismiss(t.id)} + variant="filled" + sx={{ width: "100%" }} + > + {t.message} + + + ))} + + + ); +} diff --git a/frontend/src/components/UserPicker.tsx b/frontend/src/components/UserPicker.tsx new file mode 100644 index 0000000..88c2378 --- /dev/null +++ b/frontend/src/components/UserPicker.tsx @@ -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 ( + onChange(v as never)} + getOptionLabel={(u: UserOut) => u.display_name} + isOptionEqualToValue={(a: UserOut, b: UserOut) => a.id === b.id} + renderInput={(params) => ( + + )} + /> + ); +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..4a85918 --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,22 @@ +export interface AppConfig { + authMode: "entra" | "dev"; + tenantId: string; + clientId: string; + apiScope: string; +} + +declare global { + interface Window { + __APP_CONFIG__?: Partial; + } +} + +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` : ""), +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..f9a26c7 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + + + + + + + + + + , + ); +} + +void bootstrap(); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..c3d4c1b --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -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(""); + + 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 ( + + + Dashboard + + + + + { + setTab(v); + setPage(1); + }} + variant="scrollable" + scrollButtons="auto" + sx={{ borderBottom: 1, borderColor: "divider" }} + > + {queues.map((q) => ( + + ))} + + + + {isNewRequests && ( + <> + { + setJobFilter(e.target.value); + setPage(1); + }} + /> + { + setAuthorityFilter( + e.target.value === "" ? "" : Number(e.target.value), + ); + setPage(1); + }} + sx={{ minWidth: 220 }} + > + All + {(authorities.data ?? []).map((u) => ( + + {u.display_name} + + ))} + + + )} + + + + + + + + + ); +} diff --git a/frontend/src/pages/NcrDetailPage.tsx b/frontend/src/pages/NcrDetailPage.tsx new file mode 100644 index 0000000..773e241 --- /dev/null +++ b/frontend/src/pages/NcrDetailPage.tsx @@ -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 ( + + + + {children} + + ); +} + +function AuditTab({ ncrId }: { ncrId: number }) { + const audit = useNcrAudit(ncrId, true); + if (audit.isLoading) return ; + if (audit.isError) + return {(audit.error as Error).message}; + const items = audit.data?.items ?? []; + return ( + + + + When + Who + Action + Field + Before + After + + + + {items.map((a) => ( + + {fmt(a.created_at)} + {a.user.display_name} + + + {a.detail && ( + + {a.detail} + + )} + + {a.field_name ?? ""} + + {a.old_value ?? ""} + + + {a.new_value ?? ""} + + + ))} + +
+ ); +} + +function DetailBody({ ncr }: { ncr: NcrDetail }) { + const actions = ncr.available_actions; + const closed = ncr.stage === "closed"; + + return ( + <> + {closed && ( + } 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. + + )} + + + + {ncr.job_number} + {new Date(ncr.created_at).toLocaleDateString()} + {ncr.department} + {ncr.deviation_category} + {ncr.requester.display_name} + + {ncr.disposition_authority.display_name} + + {ncr.job_info && ( + <> + + {[ncr.job_info.part_id, ncr.job_info.part_description] + .filter(Boolean) + .join(" — ")} + + {ncr.job_info.customer_name} + {ncr.job_info.work_order_status} + + )} + + + Deviation Detail + + {ncr.deviation_detail} + + + + + Attachments + + + + + + {ncr.stage === "new_request" && !actions.includes("initial_disposition") ? ( + + Awaiting initial disposition by {ncr.disposition_authority.display_name}. + + ) : ( + + {ncr.qc_authority} + {ncr.work_order} + + {ncr.secondary_review_needed === null + ? "—" + : ncr.secondary_review_needed + ? `Yes — ${ncr.secondary_authorities.map((u) => u.display_name).join(", ") || "unassigned"}` + : "No"} + + {ncr.disposition_notes && ( + + + Disposition Notes + + + + )} + + )} + {actions.includes("initial_disposition") && ( + <> + + + + + + )} + {actions.includes("secondary_disposition") && ( + <> + + + + + + )} + + + + + + {ncr.operations_complete ? "Yes" : "Pending"} + + + {ncr.operations_completed_by?.display_name} + + {fmt(ncr.operations_completed_at)} + + {actions.includes("operations_complete") && ( + <> + + + + + + )} + + + + + + {ncr.qc_approval === null ? "—" : ncr.qc_approval === "yes" ? "Yes" : "No"} + + + {ncr.qc_closed + ? `Yes — ${ncr.qc_closed_by?.display_name}, ${fmt(ncr.qc_closed_at)}` + : "Pending"} + + {ncr.inspection_notes && ( + + + Inspection Notes + + {ncr.inspection_notes} + + )} + + {actions.includes("inspection") && ( + <> + + + + + + )} + + + + + {money(ncr.labor_cost)} + {money(ncr.material_cost)} + {money(ncr.service_cost)} + {money(ncr.other_cost)} + + + {money(ncr.total_cost)} + + + + {ncr.costing_completed_by + ? `${ncr.costing_completed_by.display_name}, ${fmt(ncr.costing_completed_at)}` + : null} + + + {actions.includes("costing") && ( + <> + + + + + + )} + + + + + + + When + Action + From + To + By + + + + {ncr.transitions.map((t) => ( + + {fmt(t.acted_at)} + + {t.action.replace(/_/g, " ")} + {t.note && ( + + {t.note} + + )} + + {t.from_stage ? STAGE_LABELS[t.from_stage] : "—"} + {STAGE_LABELS[t.to_stage]} + {t.acted_by.display_name} + + ))} + +
+
+ + ); +} + +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 ( + + + + ); + } + if (ncrQuery.isError || !ncrQuery.data) { + return ( + + {(ncrQuery.error as Error | undefined)?.message ?? "NCR not found."} + + ); + } + const ncr = ncrQuery.data; + const canAudit = + ncr.available_actions.includes("view_audit") || + (me.data?.roles.includes("admin") ?? false); + + return ( + + + + {ncr.ncr_number} + + + {ncr.days_in_stage}d in stage + + + + + {ncr.available_actions.includes("reopen") && ( + + )} + + + + + + {canAudit ? ( + <> + setTab(v)} sx={{ mb: 2 }}> + + } iconPosition="start" label="Audit History" /> + + {tab === 0 ? ( + + ) : ( + + + + + + )} + + ) : ( + + )} + + setReopenOpen(false)} /> + + ); +} diff --git a/frontend/src/pages/NewNcrPage.tsx b/frontend/src/pages/NewNcrPage.tsx new file mode 100644 index 0000000..38bc3b6 --- /dev/null +++ b/frontend/src/pages/NewNcrPage.tsx @@ -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(""); + const [categoryId, setCategoryId] = useState(""); + const [authority, setAuthority] = useState(null); + const [detail, setDetail] = useState(""); + const [created, setCreated] = useState(null); + + const create = useMutation({ + mutationFn: () => + api("/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 ( + + + + + + NCR submitted + + + {created.ncr_number} + + + Job {created.job_number} · {created.department} ·{" "} + {created.deviation_category} + + + {created.disposition_authority.display_name} has been notified for + initial disposition. + + + + + Add photos / files now (optional) + + + + + + + + + + + + ); + } + + return ( + + + New NCR Request + + + + + {create.isError && ( + {(create.error as Error).message} + )} + + setDepartmentId(Number(e.target.value))} + > + {(lookups.data?.departments ?? []).map((d) => ( + + {d.name} + + ))} + + setCategoryId(Number(e.target.value))} + > + {(lookups.data?.deviation_categories ?? []).map((c) => ( + + {c.name} + + ))} + + setAuthority(v as UserOut | null)} + required + helperText="Who should review this nonconformance?" + /> + setDetail(e.target.value)} + required + multiline + minRows={4} + helperText="Describe what was found, where, and how many pieces are affected." + /> + + Photos and file attachments can be added on the next screen, right + after the NCR number is assigned. + + + + + + + ); +} diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx new file mode 100644 index 0000000..5933782 --- /dev/null +++ b/frontend/src/pages/ReportsPage.tsx @@ -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[]): 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 ( + + + + {label} + + {value} + + + ); +} + +function ChartCard({ + title, + subheader, + action, + children, +}: { + title: string; + subheader?: string; + action?: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + + + {children} + + ); +} + +export function ReportsPage() { + const lookups = useLookups(); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [departmentId, setDepartmentId] = useState(""); + const [categoryId, setCategoryId] = useState(""); + 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 ( + + + Reports + + + {/* Filters — one row above the charts */} + + + + setDateFrom(e.target.value)} + /> + setDateTo(e.target.value)} + /> + + setDepartmentId(e.target.value === "" ? "" : Number(e.target.value)) + } + sx={{ minWidth: 180 }} + > + All departments + {(lookups.data?.departments ?? []).map((d) => ( + + {d.name} + + ))} + + + setCategoryId(e.target.value === "" ? "" : Number(e.target.value)) + } + sx={{ minWidth: 180 }} + > + All categories + {(lookups.data?.deviation_categories ?? []).map((c) => ( + + {c.name} + + ))} + + + + + + {summary.isLoading || !data ? ( + + + + ) : ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + setCostAsTable(!costAsTable)} + title="Toggle table view" + > + + + + + } + > + {costAsTable ? ( + + + + Month + Labor + Material + Service + Other + Total + + + + {costRows.map((r) => ( + + {r.month} + {money(r.Labor)} + {money(r.Material)} + {money(r.Service)} + {money(r.Other)} + + {money(r.Total)} + + + ))} + +
+ ) : ( + + + + + money(v)} + width={72} + /> + money(v)} /> + + {/* 2px surface gap between stacked segments via stroke */} + + + + + + + )} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `${v} days`} /> + + + + + + + + } + onClick={() => csvDownload("top-jobs.csv", data.top_jobs)} + > + CSV + + } + > + + + + Job Number + NCRs + + + + {data.top_jobs.map((j) => ( + + {j.job_number} + {j.count} + + ))} + {data.top_jobs.length === 0 && ( + + + No data. + + + )} + +
+
+
+
+ + )} +
+ ); +} diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx new file mode 100644 index 0000000..9d1072a --- /dev/null +++ b/frontend/src/pages/SearchPage.tsx @@ -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(""); + const [categoryId, setCategoryId] = useState(""); + 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 = (setter: (v: T) => void) => (v: T) => { + setter(v); + setPage(1); + }; + + return ( + + + Search NCRs + + + + + + set(setQ)(e.target.value)} + /> + + + + set(setDepartmentId)(e.target.value === "" ? "" : Number(e.target.value)) + } + > + All + {(lookups.data?.departments ?? []).map((d) => ( + + {d.name} + + ))} + + + + + set(setCategoryId)(e.target.value === "" ? "" : Number(e.target.value)) + } + > + All + {(lookups.data?.deviation_categories ?? []).map((c) => ( + + {c.name} + + ))} + + + + set(setStage)(e.target.value)} + > + All + {STAGE_ORDER.map((s) => ( + + {STAGE_LABELS[s]} + + ))} + + + + set(setDateFrom)(e.target.value)} + /> + + + set(setDateTo)(e.target.value)} + /> + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/StageForms.tsx b/frontend/src/pages/StageForms.tsx new file mode 100644 index 0000000..d5c0b4f --- /dev/null +++ b/frontend/src/pages/StageForms.tsx @@ -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([]); + + const mutation = useNcrMutation( + () => + api(`/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 ( + + {mutation.isError && ( + {(mutation.error as Error).message} + )} + setQcAuthority(e.target.value)} + /> + setWorkOrder(e.target.value)} + /> + + setSecondary(e.target.checked)} /> + } + label="Secondary review needed" + /> + {secondary && ( + setAssignees((v as UserOut[]) ?? [])} + helperText="The selected secondary disposition authorities will see this NCR in their personal queue." + required + /> + )} + + + ); +} + +// ── 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(`/api/ncrs/${ncr.id}/secondary-disposition`, { + method: "POST", + body: { + qc_authority: qcAuthority || null, + work_order: workOrder || null, + disposition_notes: notes || null, + release, + }, + }), + warnings, + ); + + return ( + + {mutation.isError && ( + {(mutation.error as Error).message} + )} + setQcAuthority(e.target.value)} + /> + setWorkOrder(e.target.value)} + /> + + + + + + + ); +} + +// ── Operations ─────────────────────────────────────────────────────────────── +export function OperationsForm({ ncr }: { ncr: NcrDetail }) { + const { warnings, toast } = useWarnToast(); + const mutation = useNcrMutation( + () => + api(`/api/ncrs/${ncr.id}/operations-complete`, { + method: "POST", + }), + warnings, + ); + return ( + + {mutation.isError && ( + {(mutation.error as Error).message} + )} + + Review the disposition above, complete the rework/repair, then mark + operations complete to send this NCR to QC Inspection. + + + + ); +} + +// ── 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(`/api/ncrs/${ncr.id}/inspection`, { + method: "POST", + body: { + qc_approval: approval, + inspection_notes: notes || null, + qc_closed: close, + }, + }), + warnings, + ); + + return ( + + {mutation.isError && ( + {(mutation.error as Error).message} + )} + + QC Approval: + setApproval(v)} + size="small" + > + + Yes + + + No + + + + setNotes(e.target.value)} + multiline + minRows={3} + /> + setClose(e.target.checked)} />} + label="QC Closed — send to Costing (inspection can no longer be edited)" + /> + + + ); +} + +// ── Costing ────────────────────────────────────────────────────────────────── +function CostField({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (v: string) => void; +}) { + return ( + { + const v = e.target.value; + if (/^\d*\.?\d{0,2}$/.test(v)) onChange(v); + }} + inputProps={{ inputMode: "decimal" }} + InputProps={{ startAdornment: $ }} + 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(`/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 ( + + {mutation.isError && ( + {(mutation.error as Error).message} + )} + + + + + + + Total: {total} + + Saving costs completes the workflow and closes this NCR. A closed NCR is + locked; only an Admin can reopen it. + + + + ); +} + +// ── 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("costing"); + const [reason, setReason] = useState(""); + + const mutation = useNcrMutation( + () => + api(`/api/ncrs/${ncr.id}/reopen`, { + method: "POST", + body: { to_stage: target, reason: reason.trim() }, + }), + warnings, + ); + + return ( + + Reopen {ncr.ncr_number} + + + {mutation.isError && ( + {(mutation.error as Error).message} + )} + setTarget(e.target.value)} + > + {REOPEN_TARGETS.map((s) => ( + + {STAGE_LABELS[s]} + + ))} + + setReason(e.target.value)} + multiline + minRows={2} + required + /> + + + + + + + + ); +} diff --git a/frontend/src/pages/admin/AdminAuditPage.tsx b/frontend/src/pages/admin/AdminAuditPage.tsx new file mode 100644 index 0000000..0927aa2 --- /dev/null +++ b/frontend/src/pages/admin/AdminAuditPage.tsx @@ -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( + `/api/admin/audit${buildQuery({ page, page_size: 50, ncr_number: ncrNumber })}`, + ), + placeholderData: (prev) => prev, + }); + + return ( + + + + { + setNcrNumber(e.target.value); + setPage(1); + }} + /> + + Immutable system-wide audit trail (field-level before/after values). + + + + + + When + Who + Action + Field + Before + After + + + + {(audit.data?.items ?? []).map((a) => ( + + + {new Date(a.created_at).toLocaleString()} + + {a.user.display_name} + + + {a.detail && ( + + {a.detail} + + )} + + {a.field_name ?? ""} + + {a.old_value ?? ""} + + + {a.new_value ?? ""} + + + ))} + +
+ setPage(p + 1)} + rowsPerPage={50} + rowsPerPageOptions={[50]} + /> +
+
+ ); +} diff --git a/frontend/src/pages/admin/AdminListsPage.tsx b/frontend/src/pages/admin/AdminListsPage.tsx new file mode 100644 index 0000000..a8ef7b5 --- /dev/null +++ b/frontend/src/pages/admin/AdminListsPage.tsx @@ -0,0 +1,145 @@ +import AddIcon from "@mui/icons-material/Add"; +import { + Button, + Card, + CardContent, + CardHeader, + Divider, + Grid, + Stack, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { api } from "../../api/client"; +import type { NamedLookup } from "../../api/types"; +import { useToast } from "../../components/Toast"; + +function LookupManager({ + title, + endpoint, + helper, +}: { + title: string; + endpoint: string; // /api/admin/departments or /api/admin/categories + helper: string; +}) { + const qc = useQueryClient(); + const { toast } = useToast(); + const [newName, setNewName] = useState(""); + + const items = useQuery({ + queryKey: ["admin-lookup", endpoint], + queryFn: () => api(endpoint), + }); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ["admin-lookup", endpoint] }); + qc.invalidateQueries({ queryKey: ["lookups"] }); + }; + + const create = useMutation({ + mutationFn: () => api(endpoint, { method: "POST", body: { name: newName } }), + onSuccess: () => { + setNewName(""); + invalidate(); + toast("Added."); + }, + onError: (e) => toast(e.message, "error"), + }); + + const patch = useMutation({ + mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) => + api(`${endpoint}/${id}`, { method: "PATCH", body: { is_active } }), + onSuccess: invalidate, + onError: (e) => toast(e.message, "error"), + }); + + return ( + + + + + + setNewName(e.target.value)} + fullWidth + /> + + + + + + Name + Active + + + + {(items.data ?? []).map((item) => ( + + + {item.name} + + + + patch.mutate({ id: item.id, is_active: e.target.checked }) + } + /> + + + ))} + +
+
+
+ ); +} + +export function AdminListsPage() { + return ( + <> + + Values are never deleted — deactivating hides them from new NCRs while + existing records keep their history. + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/admin/AdminPage.tsx b/frontend/src/pages/admin/AdminPage.tsx new file mode 100644 index 0000000..2cb600f --- /dev/null +++ b/frontend/src/pages/admin/AdminPage.tsx @@ -0,0 +1,60 @@ +import { + Alert, + Box, + Tab, + Tabs, + Typography, +} from "@mui/material"; +import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; +import { useMe } from "../../api/hooks"; +import { AdminAuditPage } from "./AdminAuditPage"; +import { AdminListsPage } from "./AdminListsPage"; +import { AdminSettingsPage } from "./AdminSettingsPage"; +import { AdminUsersPage } from "./AdminUsersPage"; + +const TABS = [ + { path: "users", label: "Users & Roles" }, + { path: "lists", label: "Departments & Categories" }, + { path: "settings", label: "Settings" }, + { path: "audit", label: "Audit Log" }, +]; + +export function AdminPage() { + const me = useMe(); + const navigate = useNavigate(); + const location = useLocation(); + + if (me.data && !me.data.roles.includes("admin")) { + return The Admin area requires the Admin role.; + } + + const current = TABS.findIndex((t) => + location.pathname.includes(`/admin/${t.path}`), + ); + + return ( + + + Administration + + navigate(`/admin/${TABS[v].path}`)} + variant="scrollable" + scrollButtons="auto" + sx={{ mb: 2, borderBottom: 1, borderColor: "divider" }} + > + {TABS.map((t) => ( + + ))} + + + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/pages/admin/AdminSettingsPage.tsx b/frontend/src/pages/admin/AdminSettingsPage.tsx new file mode 100644 index 0000000..da8ec16 --- /dev/null +++ b/frontend/src/pages/admin/AdminSettingsPage.tsx @@ -0,0 +1,66 @@ +import { + Card, + CardContent, + FormControlLabel, + Switch, + Typography, +} from "@mui/material"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../../api/client"; +import { useToast } from "../../components/Toast"; + +interface Settings { + notifications_enabled: boolean; +} + +export function AdminSettingsPage() { + const qc = useQueryClient(); + const { toast } = useToast(); + + const settings = useQuery({ + queryKey: ["admin-settings"], + queryFn: () => api("/api/admin/settings"), + }); + + const save = useMutation({ + mutationFn: (notifications_enabled: boolean) => + api("/api/admin/settings", { + method: "PUT", + body: { notifications_enabled }, + }), + onSuccess: (data) => { + qc.setQueryData(["admin-settings"], data); + toast( + data.notifications_enabled + ? "Email notifications enabled." + : "Email notifications disabled.", + ); + }, + onError: (e) => toast(e.message, "error"), + }); + + return ( + + + + Notifications + + save.mutate(e.target.checked)} + /> + } + label="Send stage-transition emails (Microsoft Graph, from the acting user's mailbox)" + /> + + Turn this off during testing to stop all outgoing email. Workflow + transitions always proceed even if an email fails — failures surface + as non-blocking warnings and are written to the API log. + + + + ); +} diff --git a/frontend/src/pages/admin/AdminUsersPage.tsx b/frontend/src/pages/admin/AdminUsersPage.tsx new file mode 100644 index 0000000..8530ca5 --- /dev/null +++ b/frontend/src/pages/admin/AdminUsersPage.tsx @@ -0,0 +1,174 @@ +import { + Button, + Card, + CardContent, + Checkbox, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Stack, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { api } from "../../api/client"; +import type { Role, UserOut } from "../../api/types"; +import { ROLE_LABELS, ROLES } from "../../api/types"; +import { useToast } from "../../components/Toast"; + +function RolesDialog({ + user, + onClose, +}: { + user: UserOut; + onClose: () => void; +}) { + const qc = useQueryClient(); + const { toast } = useToast(); + const [roles, setRoles] = useState>(new Set(user.roles)); + + const save = useMutation({ + mutationFn: () => + api(`/api/admin/users/${user.id}/roles`, { + method: "PUT", + body: { roles: Array.from(roles) }, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin-users"] }); + qc.invalidateQueries({ queryKey: ["users"] }); + toast("Roles updated."); + onClose(); + }, + onError: (e) => toast(e.message, "error"), + }); + + return ( + + Roles — {user.display_name} + + + {ROLES.map((role) => ( + { + const next = new Set(roles); + if (e.target.checked) next.add(role); + else next.delete(role); + setRoles(next); + }} + /> + } + label={ROLE_LABELS[role]} + /> + ))} + + + + + + + + ); +} + +export function AdminUsersPage() { + const qc = useQueryClient(); + const { toast } = useToast(); + const [search, setSearch] = useState(""); + const [editing, setEditing] = useState(null); + + const users = useQuery({ + queryKey: ["admin-users", search], + queryFn: () => + api(`/api/admin/users${search ? `?search=${encodeURIComponent(search)}` : ""}`), + }); + + const setActive = useMutation({ + mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) => + api(`/api/admin/users/${id}/active`, { + method: "PUT", + body: { is_active }, + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }), + onError: (e) => toast(e.message, "error"), + }); + + return ( + + + + setSearch(e.target.value)} + /> + + Users are auto-provisioned on first sign-in; assign workflow roles here. + + + + + + Name + Email + Roles + Active + + + + + {(users.data ?? []).map((u) => ( + + {u.display_name} + {u.email} + + + {u.roles.map((r) => ( + + ))} + + + + + setActive.mutate({ id: u.id, is_active: e.target.checked }) + } + /> + + + + + + ))} + +
+
+ {editing && setEditing(null)} />} +
+ ); +} diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts new file mode 100644 index 0000000..42e01c0 --- /dev/null +++ b/frontend/src/theme.ts @@ -0,0 +1,36 @@ +import { createTheme } from "@mui/material/styles"; + +export const theme = createTheme({ + palette: { + primary: { main: "#1a5fb4" }, + secondary: { main: "#e66100" }, + background: { default: "#f4f6f9" }, + }, + typography: { + fontFamily: '"Segoe UI", Roboto, Helvetica, Arial, sans-serif', + h5: { fontWeight: 700 }, + h6: { fontWeight: 700 }, + }, + components: { + // Shop-floor tablets: keep touch targets comfortable. + MuiButton: { + defaultProps: { size: "medium" }, + styleOverrides: { root: { minHeight: 42, textTransform: "none", fontWeight: 600 } }, + }, + MuiTextField: { defaultProps: { size: "medium" } }, + MuiCard: { + styleOverrides: { + root: { borderRadius: 10, boxShadow: "0 1px 4px rgba(20,40,80,0.10)" }, + }, + }, + }, +}); + +export const STAGE_COLORS: Record = { + new_request: { bg: "#fdf1d9", fg: "#8a5a00" }, + secondary_disposition: { bg: "#f3e5f5", fg: "#6a1b9a" }, + operations: { bg: "#e3f2fd", fg: "#0d47a1" }, + qc_inspection: { bg: "#e8f5e9", fg: "#1b5e20" }, + costing: { bg: "#fff3e0", fg: "#b45309" }, + closed: { bg: "#eceff1", fg: "#455a64" }, +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d1776b4 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.d.ts b/frontend/vite.config.d.ts new file mode 100644 index 0000000..340562a --- /dev/null +++ b/frontend/vite.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vite").UserConfig; +export default _default; diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..a33f795 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,25 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + // Local development against `uvicorn app.main:app` on :8000 + "/api": "http://localhost:8000", + }, + }, + build: { + chunkSizeWarningLimit: 1200, + rollupOptions: { + output: { + manualChunks: { + mui: ["@mui/material", "@mui/icons-material"], + charts: ["recharts"], + editor: ["@tiptap/react", "@tiptap/starter-kit", "@tiptap/extension-link"], + msal: ["@azure/msal-browser", "@azure/msal-react"], + }, + }, + }, + }, +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4a544ca --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,26 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + // Local development against `uvicorn app.main:app` on :8000 + "/api": "http://localhost:8000", + }, + }, + build: { + chunkSizeWarningLimit: 1200, + rollupOptions: { + output: { + manualChunks: { + mui: ["@mui/material", "@mui/icons-material"], + charts: ["recharts"], + editor: ["@tiptap/react", "@tiptap/starter-kit", "@tiptap/extension-link"], + msal: ["@azure/msal-browser", "@azure/msal-react"], + }, + }, + }, + }, +}); diff --git a/scripts/powerbi_grants.sql b/scripts/powerbi_grants.sql new file mode 100644 index 0000000..15772af --- /dev/null +++ b/scripts/powerbi_grants.sql @@ -0,0 +1,14 @@ +-- Re-runnable script to (re)create the read-only Power BI reporting account. +-- Use when the MySQL volume was initialized before POWERBI_RO_PASSWORD was set: +-- +-- docker compose exec -T mysql \ +-- sh -c 'mysql -u root -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' < scripts/powerbi_grants.sql +-- +-- Then set the password (replace the placeholder): +-- ALTER USER 'powerbi_ro'@'%' IDENTIFIED BY ''; + +CREATE USER IF NOT EXISTS 'powerbi_ro'@'%' IDENTIFIED BY 'CHANGE_ME_NOW'; +GRANT SELECT ON `pesco_ncr`.`vw_ncr_full` TO 'powerbi_ro'@'%'; +GRANT SELECT ON `pesco_ncr`.`vw_ncr_stage_history` TO 'powerbi_ro'@'%'; +GRANT SELECT ON `pesco_ncr`.`vw_ncr_costs` TO 'powerbi_ro'@'%'; +FLUSH PRIVILEGES;