Initial commit: PESCO NCR system

Complete Non-Conformance Report system replacing the PowerApps/SharePoint
prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend,
React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS,
group-gated), Microsoft Graph delegated Mail.Send notifications (OBO),
six-stage workflow state machine with server-side enforcement, atomic
NCR-YYYY-NNNN numbering, attachments with camera capture, immutable
field-level audit trail, admin reopen, reports + CSV export, WeasyPrint
PDF traveler, Power BI reporting views + read-only DB user, documented
VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose
deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ang3l12
2026-07-13 11:41:22 -06:00
commit dea316b113
111 changed files with 13817 additions and 0 deletions

4
frontend/.dockerignore Normal file
View File

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

13
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci 2>/dev/null || npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
COPY docker-entrypoint.d/50-config.sh /docker-entrypoint.d/50-config.sh
RUN chmod +x /docker-entrypoint.d/50-config.sh
EXPOSE 80

View File

@@ -0,0 +1,20 @@
#!/bin/sh
# Generates the SPA's runtime configuration from container environment
# variables (nginx image runs every /docker-entrypoint.d/*.sh on start).
set -e
API_SCOPE="${ENTRA_API_SCOPE}"
if [ -z "$API_SCOPE" ] && [ -n "$ENTRA_CLIENT_ID" ]; then
API_SCOPE="api://${ENTRA_CLIENT_ID}/access_as_user"
fi
cat > /usr/share/nginx/html/config.js <<EOF
window.__APP_CONFIG__ = {
authMode: "${AUTH_MODE:-entra}",
tenantId: "${ENTRA_TENANT_ID}",
clientId: "${ENTRA_CLIENT_ID}",
apiScope: "${API_SCOPE}"
};
EOF
echo "[frontend] config.js generated (authMode=${AUTH_MODE:-entra})"

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1a5fb4" />
<title>PESCO NCR</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='18' fill='%231a5fb4'/><text x='50' y='68' font-size='52' text-anchor='middle' fill='white' font-family='Arial' font-weight='bold'>N</text></svg>" />
<!-- Runtime configuration, generated from env by the nginx entrypoint -->
<script src="/config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

33
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,33 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Attachment uploads flow through this proxy; keep in sync with MAX_UPLOAD_MB.
client_max_body_size 50m;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location /api/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
location = /config.js {
add_header Cache-Control "no-store";
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}

3671
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "pesco-ncr-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@azure/msal-browser": "^3.26.1",
"@azure/msal-react": "^2.1.1",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.7",
"@mui/material": "^5.16.7",
"@tanstack/react-query": "^5.59.0",
"@tiptap/extension-link": "^2.9.1",
"@tiptap/react": "^2.9.1",
"@tiptap/starter-kit": "^2.9.1",
"dayjs": "^1.11.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"recharts": "^2.13.0"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "~5.5.4",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,8 @@
// Local development defaults. In Docker this file is REPLACED at container
// start by docker-entrypoint.d/50-config.sh using the real environment.
window.__APP_CONFIG__ = {
authMode: "dev",
tenantId: "",
clientId: "",
apiScope: "",
};

24
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import { AdminPage } from "./pages/admin/AdminPage";
import { DashboardPage } from "./pages/DashboardPage";
import { NcrDetailPage } from "./pages/NcrDetailPage";
import { NewNcrPage } from "./pages/NewNcrPage";
import { ReportsPage } from "./pages/ReportsPage";
import { SearchPage } from "./pages/SearchPage";
export default function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/ncrs/new" element={<NewNcrPage />} />
<Route path="/ncrs/:id" element={<NcrDetailPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/admin/*" element={<AdminPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
);
}

135
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,135 @@
import {
InteractionRequiredAuthError,
PublicClientApplication,
} from "@azure/msal-browser";
import { config } from "../config";
export const msalInstance =
config.authMode === "entra"
? new PublicClientApplication({
auth: {
clientId: config.clientId,
authority: `https://login.microsoftonline.com/${config.tenantId}`,
redirectUri: window.location.origin,
postLogoutRedirectUri: window.location.origin,
},
cache: { cacheLocation: "sessionStorage" },
})
: null;
const DEV_USER_KEY = "pesco-ncr-dev-user";
export function getDevUser(): string {
return localStorage.getItem(DEV_USER_KEY) || "admin@pescoinc.biz";
}
export function setDevUser(email: string): void {
localStorage.setItem(DEV_USER_KEY, email);
}
async function authHeaders(): Promise<Record<string, string>> {
if (config.authMode === "dev") {
return { "X-Dev-User": getDevUser() };
}
const instance = msalInstance!;
const account = instance.getActiveAccount() ?? instance.getAllAccounts()[0];
if (!account) {
await instance.loginRedirect({ scopes: [config.apiScope] });
throw new Error("Redirecting to sign in…");
}
try {
const result = await instance.acquireTokenSilent({
scopes: [config.apiScope],
account,
});
return { Authorization: `Bearer ${result.accessToken}` };
} catch (err) {
if (err instanceof InteractionRequiredAuthError) {
await instance.acquireTokenRedirect({ scopes: [config.apiScope], account });
}
throw err;
}
}
export class ApiError extends Error {
status: number;
constructor(status: number, detail: string) {
super(detail);
this.status = status;
}
}
async function parseError(resp: Response): Promise<ApiError> {
let detail = `Request failed (${resp.status})`;
try {
const body = await resp.json();
if (typeof body.detail === "string") detail = body.detail;
else if (Array.isArray(body.detail) && body.detail[0]?.msg)
detail = body.detail
.map((d: { loc?: unknown[]; msg: string }) => d.msg)
.join("; ");
} catch {
/* non-JSON body */
}
return new ApiError(resp.status, detail);
}
export async function api<T>(
path: string,
options: { method?: string; body?: unknown } = {},
): Promise<T> {
const headers: Record<string, string> = await authHeaders();
const init: RequestInit = { method: options.method ?? "GET", headers };
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(options.body);
}
const resp = await fetch(path, init);
if (!resp.ok) throw await parseError(resp);
if (resp.status === 204) return undefined as T;
return (await resp.json()) as T;
}
export async function apiUpload<T>(path: string, form: FormData): Promise<T> {
const headers = await authHeaders();
const resp = await fetch(path, { method: "POST", headers, body: form });
if (!resp.ok) throw await parseError(resp);
return (await resp.json()) as T;
}
export async function apiBlob(path: string): Promise<Blob> {
const headers = await authHeaders();
const resp = await fetch(path, { headers });
if (!resp.ok) throw await parseError(resp);
return resp.blob();
}
/** Fetch a protected file and hand it to the browser (download or new tab). */
export async function openBlob(
path: string,
filename: string,
mode: "download" | "open",
): Promise<void> {
const blob = await apiBlob(path);
const url = URL.createObjectURL(blob);
if (mode === "open") {
window.open(url, "_blank");
} else {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
}
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function buildQuery(params: Record<string, unknown>): string {
const q = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
q.set(key, String(value));
}
}
const s = q.toString();
return s ? `?${s}` : "";
}

121
frontend/src/api/hooks.ts Normal file
View File

@@ -0,0 +1,121 @@
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { api, apiUpload, buildQuery } from "./client";
import type {
AttachmentOut,
AuditListOut,
JobLookupOut,
LookupsOut,
MeOut,
NcrDetail,
NcrListOut,
NcrMutationOut,
QueueFilters,
ReportsSummary,
UserOut,
} from "./types";
export function useMe() {
return useQuery({
queryKey: ["me"],
queryFn: () => api<MeOut>("/api/me"),
staleTime: 5 * 60_000,
retry: 1,
});
}
export function useLookups() {
return useQuery({
queryKey: ["lookups"],
queryFn: () => api<LookupsOut>("/api/lookups"),
staleTime: 5 * 60_000,
});
}
export function useUsersByRole(role: string) {
return useQuery({
queryKey: ["users", role],
queryFn: () => api<UserOut[]>(`/api/users?role=${role}`),
staleTime: 60_000,
});
}
export function useQueue(queue: string, filters: QueueFilters, page: number, pageSize = 25) {
return useQuery({
queryKey: ["ncrs", queue, filters, page, pageSize],
queryFn: () =>
api<NcrListOut>(
`/api/ncrs${buildQuery({ queue, page, page_size: pageSize, ...filters })}`,
),
placeholderData: (prev) => prev,
});
}
export function useNcr(id: number | undefined) {
return useQuery({
queryKey: ["ncr", id],
queryFn: () => api<NcrDetail>(`/api/ncrs/${id}`),
enabled: id !== undefined,
});
}
export function useNcrAudit(id: number, enabled: boolean) {
return useQuery({
queryKey: ["ncr-audit", id],
queryFn: () => api<AuditListOut>(`/api/ncrs/${id}/audit`),
enabled,
});
}
export function useJobLookup(jobNumber: string) {
return useQuery({
queryKey: ["job-lookup", jobNumber],
queryFn: () =>
api<JobLookupOut>(`/api/jobs/${encodeURIComponent(jobNumber)}/lookup`),
enabled: jobNumber.trim().length > 2,
staleTime: 60_000,
});
}
export function useReportsSummary(filters: Record<string, unknown>) {
return useQuery({
queryKey: ["reports", filters],
queryFn: () =>
api<ReportsSummary>(`/api/reports/summary${buildQuery(filters)}`),
});
}
/** Shared invalidation + warning plumbing for every NCR mutation. */
export function useNcrMutation<TVars>(
mutationFn: (vars: TVars) => Promise<NcrMutationOut>,
onWarnings?: (warnings: string[]) => void,
) {
const qc = useQueryClient();
return useMutation({
mutationFn,
onSuccess: (data) => {
qc.setQueryData(["ncr", data.ncr.id], data.ncr);
qc.invalidateQueries({ queryKey: ["ncrs"] });
qc.invalidateQueries({ queryKey: ["ncr-audit", data.ncr.id] });
if (data.warnings.length && onWarnings) onWarnings(data.warnings);
},
});
}
export function useUploadAttachments(ncrId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (files: File[]) => {
const form = new FormData();
for (const f of files) form.append("files", f, f.name);
return apiUpload<AttachmentOut[]>(`/api/ncrs/${ncrId}/attachments`, form);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ncr", ncrId] });
qc.invalidateQueries({ queryKey: ["ncr-audit", ncrId] });
},
});
}

246
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,246 @@
export type StageValue =
| "new_request"
| "secondary_disposition"
| "operations"
| "qc_inspection"
| "costing"
| "closed";
export const STAGE_LABELS: Record<StageValue, string> = {
new_request: "New Request",
secondary_disposition: "Secondary Disposition",
operations: "Operations",
qc_inspection: "QC Inspection",
costing: "Costing",
closed: "Closed",
};
export const STAGE_ORDER: StageValue[] = [
"new_request",
"secondary_disposition",
"operations",
"qc_inspection",
"costing",
"closed",
];
export const ROLES = [
"requester",
"disposition_authority",
"secondary_disposition_authority",
"operations",
"qc_inspector",
"costing",
"admin",
] as const;
export type Role = (typeof ROLES)[number];
export const ROLE_LABELS: Record<Role, string> = {
requester: "Requester",
disposition_authority: "Disposition Authority",
secondary_disposition_authority: "Secondary Disposition Authority",
operations: "Operations",
qc_inspector: "QC Inspector",
costing: "Costing",
admin: "Admin",
};
export interface UserRef {
id: number;
display_name: string;
email: string;
}
export interface UserOut extends UserRef {
employee_id: string | null;
is_active: boolean;
roles: Role[];
last_login_at: string | null;
}
export interface MeOut extends UserOut {
auth_mode: "entra" | "dev";
}
export interface NamedLookup {
id: number;
name: string;
is_active: boolean;
}
export interface LookupsOut {
departments: NamedLookup[];
deviation_categories: NamedLookup[];
}
export interface AttachmentOut {
id: number;
original_filename: string;
content_type: string;
size_bytes: number;
is_image: boolean;
uploaded_at: string;
uploaded_by: UserRef;
}
export interface TransitionOut {
id: number;
from_stage: StageValue | null;
to_stage: StageValue;
action: string;
acted_at: string;
acted_by: UserRef;
note: string | null;
}
export interface JobInfoOut {
part_id: string | null;
part_description: string | null;
customer_name: string | null;
work_order_status: string | null;
source: string;
}
export interface NcrListItem {
id: number;
ncr_number: string;
job_number: string;
department: string;
deviation_category: string;
requester: string;
disposition_authority: string;
stage: StageValue;
stage_label: string;
days_in_stage: number;
created_at: string;
}
export interface NcrListOut {
items: NcrListItem[];
total: number;
page: number;
page_size: number;
}
export type NcrAction =
| "initial_disposition"
| "secondary_disposition"
| "operations_complete"
| "inspection"
| "costing"
| "reopen"
| "add_attachment"
| "view_audit";
export interface NcrDetail {
id: number;
ncr_number: string;
job_number: string;
created_at: string;
stage: StageValue;
stage_label: string;
stage_entered_at: string;
days_in_stage: number;
department: string;
department_id: number;
deviation_category: string;
deviation_category_id: number;
deviation_detail: string;
requester: UserRef;
disposition_authority: UserRef;
qc_authority: string | null;
work_order: string | null;
disposition_notes: string | null;
secondary_review_needed: boolean | null;
secondary_authorities: UserRef[];
operations_complete: boolean;
operations_completed_at: string | null;
operations_completed_by: UserRef | null;
qc_approval: "yes" | "no" | null;
inspection_notes: string | null;
qc_closed: boolean;
qc_closed_at: string | null;
qc_closed_by: UserRef | null;
labor_cost: string | null;
material_cost: string | null;
service_cost: string | null;
other_cost: string | null;
total_cost: string | null;
costing_completed_at: string | null;
costing_completed_by: UserRef | null;
closed_at: string | null;
closed_by: UserRef | null;
job_info: JobInfoOut | null;
attachments: AttachmentOut[];
transitions: TransitionOut[];
available_actions: NcrAction[];
}
export interface NcrMutationOut {
ncr: NcrDetail;
warnings: string[];
}
export interface AuditEntry {
id: number;
created_at: string;
user: UserRef;
action: string;
field_name: string | null;
old_value: string | null;
new_value: string | null;
detail: string | null;
}
export interface AuditListOut {
items: AuditEntry[];
total: number;
}
export interface QueueFilters {
q?: string;
job_number?: string;
department_id?: number;
category_id?: number;
stage?: string;
date_from?: string;
date_to?: string;
disposition_authority_id?: number;
}
export interface ReportsSummary {
total_ncrs: number;
open_ncrs: number;
closed_ncrs: number;
total_cost: string;
by_department: { name: string; count: number }[];
by_category: { name: string; count: number }[];
by_month: { month: string; count: number }[];
cost_over_time: {
month: string;
labor: string;
material: string;
service: string;
other: string;
total: string;
}[];
aging: { bucket: string; count: number }[];
cycle_times: {
stage: StageValue;
stage_label: string;
avg_days: number;
samples: number;
}[];
end_to_end_avg_days: number | null;
top_jobs: { job_number: string; count: number }[];
}
export interface JobLookupOut {
found: boolean;
job_number: string;
part_id?: string | null;
part_description?: string | null;
customer_name?: string | null;
work_order_status?: string | null;
source?: string;
}

View File

@@ -0,0 +1,117 @@
import {
Alert,
Box,
Button,
CircularProgress,
Stack,
Typography,
} from "@mui/material";
import { MsalProvider, useIsAuthenticated, useMsal } from "@azure/msal-react";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { msalInstance } from "../api/client";
import { ApiError } from "../api/client";
import { useMe } from "../api/hooks";
import { config } from "../config";
function Centered({ children }: { children: ReactNode }) {
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 2,
}}
>
<Stack spacing={2} alignItems="center" sx={{ maxWidth: 480 }}>
{children}
</Stack>
</Box>
);
}
/** After sign-in (or in dev mode), /api/me must succeed before the app loads:
* it auto-provisions the user and enforces the front-door group. */
function MeGate({ children }: { children: ReactNode }) {
const me = useMe();
if (me.isLoading) {
return (
<Centered>
<CircularProgress />
<Typography color="text.secondary">Signing you in</Typography>
</Centered>
);
}
if (me.isError) {
const err = me.error;
const detail =
err instanceof ApiError ? err.message : "Could not reach the NCR API.";
const denied = err instanceof ApiError && err.status === 403;
return (
<Centered>
<Typography variant="h5">PESCO NCR</Typography>
<Alert severity={denied ? "warning" : "error"} sx={{ width: "100%" }}>
{denied ? "Access denied. " : ""}
{detail}
</Alert>
{denied && (
<Typography color="text.secondary" variant="body2">
Ask IT to add you to the NCR access group, then sign in again.
</Typography>
)}
<Button variant="contained" onClick={() => me.refetch()}>
Try again
</Button>
</Centered>
);
}
return <>{children}</>;
}
function EntraGate({ children }: { children: ReactNode }) {
const isAuthenticated = useIsAuthenticated();
const { instance, inProgress } = useMsal();
useEffect(() => {
if (!isAuthenticated && inProgress === "none") {
void instance.loginRedirect({ scopes: [config.apiScope] });
}
}, [isAuthenticated, inProgress, instance]);
if (!isAuthenticated) {
return (
<Centered>
<CircularProgress />
<Typography color="text.secondary">
Redirecting to Microsoft sign-in
</Typography>
</Centered>
);
}
return <MeGate>{children}</MeGate>;
}
export function AuthGate({ children }: { children: ReactNode }) {
if (config.authMode === "dev") {
return <MeGate>{children}</MeGate>;
}
if (!config.clientId || !config.tenantId) {
return (
<Centered>
<Typography variant="h5">PESCO NCR</Typography>
<Alert severity="error">
Entra ID is not configured. Set ENTRA_TENANT_ID and ENTRA_CLIENT_ID in
.env (see README), or set AUTH_MODE=dev for local development.
</Alert>
</Centered>
);
}
return (
<MsalProvider instance={msalInstance!}>
<EntraGate>{children}</EntraGate>
</MsalProvider>
);
}

View File

@@ -0,0 +1,189 @@
import AttachFileIcon from "@mui/icons-material/AttachFile";
import DescriptionIcon from "@mui/icons-material/Description";
import PhotoCameraIcon from "@mui/icons-material/PhotoCamera";
import {
Box,
Button,
CircularProgress,
Dialog,
DialogContent,
Stack,
Tooltip,
Typography,
} from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { apiBlob, openBlob } from "../api/client";
import { useUploadAttachments } from "../api/hooks";
import type { AttachmentOut } from "../api/types";
import { useToast } from "./Toast";
/** Images are behind the authenticated API, so <img src> can't load them
* directly — fetch as a blob and use an object URL. */
function useAttachmentUrl(att: AttachmentOut, enabled: boolean) {
return useQuery({
queryKey: ["attachment-blob", att.id],
queryFn: async () => {
const blob = await apiBlob(`/api/attachments/${att.id}/download`);
return URL.createObjectURL(blob);
},
enabled,
staleTime: Infinity,
gcTime: 10 * 60_000,
});
}
function Thumbnail({ att, onOpen }: { att: AttachmentOut; onOpen: (url: string) => void }) {
const url = useAttachmentUrl(att, att.is_image);
if (!att.is_image) {
return (
<Tooltip title={`${att.original_filename} — click to download`}>
<Box
onClick={() => void openBlob(`/api/attachments/${att.id}/download`, att.original_filename, "download")}
sx={{
width: 96,
height: 96,
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
p: 0.5,
}}
>
<DescriptionIcon color="action" />
<Typography variant="caption" noWrap sx={{ maxWidth: 88 }}>
{att.original_filename}
</Typography>
</Box>
</Tooltip>
);
}
return (
<Tooltip
title={`${att.original_filename}${att.uploaded_by.display_name}, ${new Date(att.uploaded_at).toLocaleString()}`}
>
<Box
onClick={() => url.data && onOpen(url.data)}
sx={{
width: 96,
height: 96,
borderRadius: 1,
overflow: "hidden",
border: "1px solid",
borderColor: "divider",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "#fafafa",
}}
>
{url.data ? (
<img
src={url.data}
alt={att.original_filename}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<CircularProgress size={20} />
)}
</Box>
</Tooltip>
);
}
interface Props {
ncrId: number;
attachments: AttachmentOut[];
canAdd: boolean;
}
export function AttachmentSection({ ncrId, attachments, canAdd }: Props) {
const upload = useUploadAttachments(ncrId);
const { toast } = useToast();
const fileInput = useRef<HTMLInputElement>(null);
const cameraInput = useRef<HTMLInputElement>(null);
const [lightbox, setLightbox] = useState<string | null>(null);
const handleFiles = (list: FileList | null) => {
if (!list || list.length === 0) return;
upload.mutate(Array.from(list), {
onSuccess: (items) =>
toast(`${items.length} attachment${items.length > 1 ? "s" : ""} added.`),
onError: (err) => toast(err.message, "error"),
});
if (fileInput.current) fileInput.current.value = "";
if (cameraInput.current) cameraInput.current.value = "";
};
return (
<Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
{attachments.map((att) => (
<Thumbnail key={att.id} att={att} onOpen={setLightbox} />
))}
{attachments.length === 0 && (
<Typography color="text.secondary" variant="body2">
No attachments yet.
</Typography>
)}
</Stack>
{canAdd && (
<Stack direction="row" spacing={1}>
<Button
startIcon={<PhotoCameraIcon />}
variant="outlined"
onClick={() => cameraInput.current?.click()}
disabled={upload.isPending}
>
Take Photo
</Button>
<Button
startIcon={upload.isPending ? <CircularProgress size={16} /> : <AttachFileIcon />}
variant="outlined"
onClick={() => fileInput.current?.click()}
disabled={upload.isPending}
>
Add Files
</Button>
{/* capture="environment" opens the rear camera on tablets/phones */}
<input
ref={cameraInput}
type="file"
accept="image/*"
capture="environment"
hidden
onChange={(e) => handleFiles(e.target.files)}
/>
<input
ref={fileInput}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.msg,.eml"
hidden
onChange={(e) => handleFiles(e.target.files)}
/>
</Stack>
)}
<Dialog open={lightbox !== null} onClose={() => setLightbox(null)} maxWidth="lg">
<DialogContent sx={{ p: 0.5 }}>
{lightbox && (
<img
src={lightbox}
alt="attachment"
style={{ maxWidth: "90vw", maxHeight: "85vh", display: "block" }}
/>
)}
</DialogContent>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,14 @@
import { Grid, Typography } from "@mui/material";
import type { ReactNode } from "react";
/** Label/value pair used across the NCR detail read-only sections. */
export function FieldRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Grid item xs={12} sm={6} md={4}>
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
<Typography component="div">{children || "—"}</Typography>
</Grid>
);
}

View File

@@ -0,0 +1,46 @@
import { Chip, Stack, TextField } from "@mui/material";
import { useEffect, useState } from "react";
import { useJobLookup } from "../api/hooks";
interface Props {
value: string;
onChange: (v: string) => void;
required?: boolean;
}
/** Job number entry. Free text today (NullJobLookupService); when the VISUAL
* provider is enabled the enrichment chips below light up automatically —
* no redesign needed. */
export function JobNumberField({ value, onChange, required }: Props) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), 400);
return () => clearTimeout(t);
}, [value]);
const lookup = useJobLookup(debounced);
const info = lookup.data;
return (
<Stack spacing={0.5}>
<TextField
label="Job Number"
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
inputProps={{ maxLength: 100 }}
/>
{info?.found && (
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{info.part_id && <Chip size="small" label={`Part: ${info.part_id}`} />}
{info.customer_name && (
<Chip size="small" label={`Customer: ${info.customer_name}`} />
)}
{info.work_order_status && (
<Chip size="small" label={`WO Status: ${info.work_order_status}`} />
)}
</Stack>
)}
</Stack>
);
}

View File

@@ -0,0 +1,197 @@
import AddCircleIcon from "@mui/icons-material/AddCircle";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import AssessmentIcon from "@mui/icons-material/Assessment";
import DashboardIcon from "@mui/icons-material/Dashboard";
import MenuIcon from "@mui/icons-material/Menu";
import SearchIcon from "@mui/icons-material/Search";
import {
AppBar,
Avatar,
Box,
Divider,
Drawer,
IconButton,
List,
ListItemButton,
ListItemIcon,
ListItemText,
MenuItem,
Select,
Toolbar,
Tooltip,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import type { ReactNode } from "react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { getDevUser, setDevUser } from "../api/client";
import { useMe } from "../api/hooks";
import { config } from "../config";
const DRAWER_WIDTH = 232;
const DEV_USERS = [
"admin@pescoinc.biz",
"dispo@pescoinc.biz",
"second@pescoinc.biz",
"ops@pescoinc.biz",
"qc@pescoinc.biz",
"cost@pescoinc.biz",
"req@pescoinc.biz",
];
function DevUserSwitcher() {
return (
<Tooltip title="AUTH_MODE=dev — switch the simulated user">
<Select
size="small"
value={getDevUser()}
onChange={(e) => {
setDevUser(e.target.value);
window.location.reload();
}}
sx={{
mr: 1,
bgcolor: "rgba(255,255,255,0.15)",
color: "#fff",
".MuiSvgIcon-root": { color: "#fff" },
fontSize: 13,
}}
>
{DEV_USERS.map((u) => (
<MenuItem key={u} value={u}>
{u.split("@")[0]}
</MenuItem>
))}
</Select>
</Tooltip>
);
}
export function Layout({ children }: { children: ReactNode }) {
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up("md"));
const [mobileOpen, setMobileOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const me = useMe();
const isAdmin = me.data?.roles.includes("admin") ?? false;
const nav = [
{ label: "Dashboard", icon: <DashboardIcon />, path: "/" },
{ label: "New NCR", icon: <AddCircleIcon />, path: "/ncrs/new" },
{ label: "Search", icon: <SearchIcon />, path: "/search" },
{ label: "Reports", icon: <AssessmentIcon />, path: "/reports" },
...(isAdmin
? [{ label: "Admin", icon: <AdminPanelSettingsIcon />, path: "/admin" }]
: []),
];
const drawer = (
<Box sx={{ pt: 1 }}>
<Toolbar sx={{ minHeight: { xs: 56, md: 64 } }}>
<Typography variant="h6" color="primary">
PESCO NCR
</Typography>
</Toolbar>
<Divider />
<List>
{nav.map((item) => {
const selected =
item.path === "/"
? location.pathname === "/"
: location.pathname.startsWith(item.path);
return (
<ListItemButton
key={item.path}
selected={selected}
onClick={() => {
navigate(item.path);
setMobileOpen(false);
}}
sx={{ minHeight: 48 }}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
);
})}
</List>
</Box>
);
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<AppBar
position="fixed"
sx={{ zIndex: theme.zIndex.drawer + 1 }}
elevation={1}
>
<Toolbar sx={{ minHeight: { xs: 56, md: 64 } }}>
{!isDesktop && (
<IconButton
color="inherit"
edge="start"
onClick={() => setMobileOpen(true)}
sx={{ mr: 1 }}
>
<MenuIcon />
</IconButton>
)}
<Typography variant="h6" sx={{ flexGrow: 1 }} noWrap>
Non-Conformance Reports
</Typography>
{config.authMode === "dev" && <DevUserSwitcher />}
{me.data && (
<Tooltip title={`${me.data.display_name} (${me.data.email})`}>
<Avatar sx={{ bgcolor: "secondary.main", width: 36, height: 36 }}>
{me.data.display_name
.split(" ")
.map((p) => p[0])
.slice(0, 2)
.join("")}
</Avatar>
</Tooltip>
)}
</Toolbar>
</AppBar>
{isDesktop ? (
<Drawer
variant="permanent"
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
"& .MuiDrawer-paper": { width: DRAWER_WIDTH, boxSizing: "border-box" },
}}
>
{drawer}
</Drawer>
) : (
<Drawer
variant="temporary"
open={mobileOpen}
onClose={() => setMobileOpen(false)}
ModalProps={{ keepMounted: true }}
sx={{ "& .MuiDrawer-paper": { width: DRAWER_WIDTH } }}
>
{drawer}
</Drawer>
)}
<Box
component="main"
sx={{
flexGrow: 1,
p: { xs: 1.5, sm: 2, md: 3 },
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
mt: { xs: "56px", md: "64px" },
}}
>
{children}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,144 @@
import {
Box,
Card,
CardActionArea,
CardContent,
CircularProgress,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import type { NcrListItem } from "../api/types";
import { StageChip } from "./StageChip";
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString();
}
interface Props {
items: NcrListItem[];
total: number;
page: number; // 1-based
pageSize: number;
onPageChange: (page: number) => void;
loading?: boolean;
}
/** Responsive queue view: a dense table on desktop, tap-friendly cards on
* phones/tablets in portrait. */
export function QueueTable({ items, total, page, pageSize, onPageChange, loading }: Props) {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
if (loading && items.length === 0) {
return (
<Box sx={{ py: 6, textAlign: "center" }}>
<CircularProgress />
</Box>
);
}
if (items.length === 0) {
return (
<Typography color="text.secondary" sx={{ py: 4, textAlign: "center" }}>
No NCRs in this queue.
</Typography>
);
}
const pagination = (
<TablePagination
component="div"
count={total}
page={page - 1}
onPageChange={(_, p) => onPageChange(p + 1)}
rowsPerPage={pageSize}
rowsPerPageOptions={[pageSize]}
/>
);
if (isSmall) {
return (
<Box>
<Stack spacing={1}>
{items.map((n) => (
<Card key={n.id} variant="outlined">
<CardActionArea onClick={() => navigate(`/ncrs/${n.id}`)}>
<CardContent sx={{ py: 1.5 }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Typography fontWeight={700}>{n.ncr_number}</Typography>
<StageChip stage={n.stage} />
</Stack>
<Typography variant="body2" color="text.secondary">
Job {n.job_number} · {n.department} · {n.deviation_category}
</Typography>
<Typography variant="body2" color="text.secondary">
{n.requester} · {fmtDate(n.created_at)} · {n.days_in_stage}d in
stage
</Typography>
</CardContent>
</CardActionArea>
</Card>
))}
</Stack>
{pagination}
</Box>
);
}
return (
<Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>NCR #</TableCell>
<TableCell>Job #</TableCell>
<TableCell>Department</TableCell>
<TableCell>Requester</TableCell>
<TableCell>Category</TableCell>
<TableCell>Stage</TableCell>
<TableCell align="right">Days in Stage</TableCell>
<TableCell align="right">Created</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((n) => (
<TableRow
key={n.id}
hover
sx={{ cursor: "pointer" }}
onClick={() => navigate(`/ncrs/${n.id}`)}
>
<TableCell sx={{ fontWeight: 700 }}>{n.ncr_number}</TableCell>
<TableCell>{n.job_number}</TableCell>
<TableCell>{n.department}</TableCell>
<TableCell>{n.requester}</TableCell>
<TableCell>{n.deviation_category}</TableCell>
<TableCell>
<StageChip stage={n.stage} />
</TableCell>
<TableCell align="right">{n.days_in_stage}</TableCell>
<TableCell align="right">{fmtDate(n.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{pagination}
</Box>
);
}

View File

@@ -0,0 +1,141 @@
import FormatBoldIcon from "@mui/icons-material/FormatBold";
import FormatItalicIcon from "@mui/icons-material/FormatItalic";
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
import FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered";
import LinkIcon from "@mui/icons-material/Link";
import RedoIcon from "@mui/icons-material/Redo";
import StrikethroughSIcon from "@mui/icons-material/StrikethroughS";
import UndoIcon from "@mui/icons-material/Undo";
import { Box, Divider, ToggleButton, Typography } from "@mui/material";
import Link from "@tiptap/extension-link";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect } from "react";
interface Props {
label?: string;
value: string;
onChange: (html: string) => void;
minHeight?: number;
}
/** Rich-text editor for disposition notes. Output HTML is sanitized again
* server-side (nh3) before storage. */
export function RichTextEditor({ label, value, onChange, minHeight = 140 }: Props) {
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({ openOnClick: false, autolink: true }),
],
content: value,
onUpdate: ({ editor }) => onChange(editor.getHTML()),
});
useEffect(() => {
if (editor && value !== editor.getHTML() && !editor.isFocused) {
editor.commands.setContent(value || "", false);
}
}, [value, editor]);
if (!editor) return null;
const btn = (
active: boolean,
onClick: () => void,
icon: React.ReactNode,
title: string,
) => (
<ToggleButton
value={title}
selected={active}
onMouseDown={(e) => {
e.preventDefault();
onClick();
}}
size="small"
sx={{ border: 0, px: 1 }}
title={title}
>
{icon}
</ToggleButton>
);
return (
<Box>
{label && (
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
)}
<Box
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
"&:focus-within": { borderColor: "primary.main" },
}}
>
<Box sx={{ display: "flex", flexWrap: "wrap", p: 0.5, gap: 0.25 }}>
{btn(
editor.isActive("bold"),
() => editor.chain().focus().toggleBold().run(),
<FormatBoldIcon fontSize="small" />,
"Bold",
)}
{btn(
editor.isActive("italic"),
() => editor.chain().focus().toggleItalic().run(),
<FormatItalicIcon fontSize="small" />,
"Italic",
)}
{btn(
editor.isActive("strike"),
() => editor.chain().focus().toggleStrike().run(),
<StrikethroughSIcon fontSize="small" />,
"Strikethrough",
)}
{btn(
editor.isActive("bulletList"),
() => editor.chain().focus().toggleBulletList().run(),
<FormatListBulletedIcon fontSize="small" />,
"Bullet list",
)}
{btn(
editor.isActive("orderedList"),
() => editor.chain().focus().toggleOrderedList().run(),
<FormatListNumberedIcon fontSize="small" />,
"Numbered list",
)}
{btn(
editor.isActive("link"),
() => {
if (editor.isActive("link")) {
editor.chain().focus().unsetLink().run();
return;
}
const url = window.prompt("Link URL (https://…)");
if (url) editor.chain().focus().setLink({ href: url }).run();
},
<LinkIcon fontSize="small" />,
"Link",
)}
<Divider flexItem orientation="vertical" sx={{ mx: 0.5 }} />
{btn(false, () => editor.chain().focus().undo().run(), <UndoIcon fontSize="small" />, "Undo")}
{btn(false, () => editor.chain().focus().redo().run(), <RedoIcon fontSize="small" />, "Redo")}
</Box>
<Divider />
<Box
sx={{
px: 1.5,
py: 1,
minHeight,
"& .ProseMirror": { outline: "none", minHeight: minHeight - 20 },
"& .ProseMirror p": { m: 0, mb: 0.5 },
}}
>
<EditorContent editor={editor} />
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,16 @@
import { Box } from "@mui/material";
/** Renders server-sanitized rich text (the API cleans all HTML with nh3
* before storing it, so this content is trusted). */
export function RichTextView({ html }: { html: string }) {
return (
<Box
sx={{
"& p": { mt: 0, mb: 0.75 },
"& ul, & ol": { mt: 0, pl: 3 },
wordBreak: "break-word",
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View File

@@ -0,0 +1,21 @@
import { Chip } from "@mui/material";
import type { StageValue } from "../api/types";
import { STAGE_LABELS } from "../api/types";
import { STAGE_COLORS } from "../theme";
export function StageChip({
stage,
size = "small",
}: {
stage: StageValue;
size?: "small" | "medium";
}) {
const colors = STAGE_COLORS[stage] ?? { bg: "#eee", fg: "#333" };
return (
<Chip
label={STAGE_LABELS[stage] ?? stage}
size={size}
sx={{ bgcolor: colors.bg, color: colors.fg, fontWeight: 600 }}
/>
);
}

View File

@@ -0,0 +1,30 @@
import { Step, StepLabel, Stepper, useMediaQuery, useTheme } from "@mui/material";
import type { NcrDetail } from "../api/types";
import { STAGE_LABELS, STAGE_ORDER } from "../api/types";
/** Visual progress through the workflow. Secondary Disposition is only shown
* when that route was taken. */
export function StageStepper({ ncr }: { ncr: NcrDetail }) {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const stages = STAGE_ORDER.filter(
(s) => s !== "secondary_disposition" || ncr.secondary_review_needed,
);
const activeIndex = stages.indexOf(ncr.stage);
return (
<Stepper
activeStep={ncr.stage === "closed" ? stages.length : activeIndex}
alternativeLabel={!isSmall}
orientation={isSmall ? "vertical" : "horizontal"}
sx={{ my: 1 }}
>
{stages.map((s) => (
<Step key={s} completed={stages.indexOf(s) < activeIndex || ncr.stage === "closed"}>
<StepLabel>{STAGE_LABELS[s]}</StepLabel>
</Step>
))}
</Stepper>
);
}

View File

@@ -0,0 +1,74 @@
import { Alert, Snackbar, Stack } from "@mui/material";
import type { ReactNode } from "react";
import { createContext, useCallback, useContext, useState } from "react";
type Severity = "success" | "info" | "warning" | "error";
interface Toast {
id: number;
message: string;
severity: Severity;
}
interface ToastContextValue {
toast: (message: string, severity?: Severity) => void;
warnings: (messages: string[]) => void;
}
const ToastContext = createContext<ToastContextValue>({
toast: () => {},
warnings: () => {},
});
export function useToast(): ToastContextValue {
return useContext(ToastContext);
}
let nextId = 1;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const toast = useCallback((message: string, severity: Severity = "success") => {
setToasts((prev) => [...prev, { id: nextId++, message, severity }]);
}, []);
const warnings = useCallback(
(messages: string[]) => {
for (const m of messages) toast(m, "warning");
},
[toast],
);
const dismiss = (id: number) =>
setToasts((prev) => prev.filter((t) => t.id !== id));
return (
<ToastContext.Provider value={{ toast, warnings }}>
{children}
<Stack
spacing={1}
sx={{ position: "fixed", bottom: 16, left: 16, zIndex: 2000, maxWidth: 420 }}
>
{toasts.map((t) => (
<Snackbar
key={t.id}
open
autoHideDuration={t.severity === "warning" ? 10000 : 4000}
onClose={() => dismiss(t.id)}
sx={{ position: "static", transform: "none" }}
>
<Alert
severity={t.severity}
onClose={() => dismiss(t.id)}
variant="filled"
sx={{ width: "100%" }}
>
{t.message}
</Alert>
</Snackbar>
))}
</Stack>
</ToastContext.Provider>
);
}

View File

@@ -0,0 +1,46 @@
import { Autocomplete, TextField } from "@mui/material";
import { useUsersByRole } from "../api/hooks";
import type { UserOut } from "../api/types";
interface Props {
role: string;
label: string;
multiple?: boolean;
value: UserOut[] | UserOut | null;
onChange: (value: UserOut[] | UserOut | null) => void;
helperText?: string;
required?: boolean;
}
/** Picker over users holding a given in-app role (drives the Disposition
* Authority dropdown and "Notify These People"). */
export function UserPicker({
role,
label,
multiple = false,
value,
onChange,
helperText,
required,
}: Props) {
const users = useUsersByRole(role);
return (
<Autocomplete
multiple={multiple}
options={users.data ?? []}
loading={users.isLoading}
value={value as never}
onChange={(_, v) => onChange(v as never)}
getOptionLabel={(u: UserOut) => u.display_name}
isOptionEqualToValue={(a: UserOut, b: UserOut) => a.id === b.id}
renderInput={(params) => (
<TextField
{...params}
label={label}
helperText={helperText}
required={required}
/>
)}
/>
);
}

22
frontend/src/config.ts Normal file
View File

@@ -0,0 +1,22 @@
export interface AppConfig {
authMode: "entra" | "dev";
tenantId: string;
clientId: string;
apiScope: string;
}
declare global {
interface Window {
__APP_CONFIG__?: Partial<AppConfig>;
}
}
const w = window.__APP_CONFIG__ ?? {};
export const config: AppConfig = {
authMode: w.authMode === "entra" ? "entra" : "dev",
tenantId: w.tenantId ?? "",
clientId: w.clientId ?? "",
apiScope:
w.apiScope || (w.clientId ? `api://${w.clientId}/access_as_user` : ""),
};

48
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { CssBaseline, ThemeProvider } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { msalInstance } from "./api/client";
import { AuthGate } from "./auth/AuthGate";
import { ToastProvider } from "./components/Toast";
import { theme } from "./theme";
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
},
});
async function bootstrap() {
if (msalInstance) {
await msalInstance.initialize();
const result = await msalInstance.handleRedirectPromise();
if (result?.account) {
msalInstance.setActiveAccount(result.account);
} else {
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) msalInstance.setActiveAccount(accounts[0]);
}
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ToastProvider>
<AuthGate>
<App />
</AuthGate>
</ToastProvider>
</BrowserRouter>
</QueryClientProvider>
</ThemeProvider>
</React.StrictMode>,
);
}
void bootstrap();

View File

@@ -0,0 +1,186 @@
import AddIcon from "@mui/icons-material/Add";
import DownloadIcon from "@mui/icons-material/Download";
import {
Box,
Button,
Card,
CardContent,
MenuItem,
Stack,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { buildQuery, openBlob } from "../api/client";
import { useMe, useQueue, useUsersByRole } from "../api/hooks";
import type { QueueFilters } from "../api/types";
import { QueueTable } from "../components/QueueTable";
import { useToast } from "../components/Toast";
interface QueueDef {
key: string;
label: string;
visible: (roles: string[]) => boolean;
}
const QUEUES: QueueDef[] = [
{ key: "my_requests", label: "My Requests", visible: () => true },
{
key: "new_requests",
label: "New Requests",
visible: (r) => r.includes("disposition_authority") || r.includes("admin"),
},
{
key: "secondary",
label: "My Secondary Queue",
visible: (r) =>
r.includes("secondary_disposition_authority") || r.includes("admin"),
},
{
key: "operations",
label: "Operations",
visible: (r) => r.includes("operations") || r.includes("admin"),
},
{
key: "inspection",
label: "QC Inspection",
visible: (r) => r.includes("qc_inspector") || r.includes("admin"),
},
{
key: "costing",
label: "Awaiting Costing",
visible: (r) => r.includes("costing") || r.includes("admin"),
},
{ key: "recently_closed", label: "Recently Closed", visible: () => true },
];
export function DashboardPage() {
const me = useMe();
const navigate = useNavigate();
const { toast } = useToast();
const roles = useMemo(() => me.data?.roles ?? [], [me.data]);
const queues = useMemo(() => QUEUES.filter((q) => q.visible(roles)), [roles]);
const [tab, setTab] = useState(0);
const [page, setPage] = useState(1);
const [jobFilter, setJobFilter] = useState("");
const [authorityFilter, setAuthorityFilter] = useState<number | "">("");
const active = queues[Math.min(tab, queues.length - 1)];
const isNewRequests = active?.key === "new_requests";
const authorities = useUsersByRole("disposition_authority");
const filters: QueueFilters = isNewRequests
? {
job_number: jobFilter || undefined,
disposition_authority_id: authorityFilter || undefined,
}
: {};
const queue = useQueue(active?.key ?? "my_requests", filters, page);
const exportCsv = () => {
void openBlob(
`/api/ncrs/export.csv${buildQuery({ queue: active.key, ...filters })}`,
`ncr-${active.key}.csv`,
"download",
).catch((e) => toast(e.message, "error"));
};
return (
<Box>
<Stack
direction={{ xs: "column", sm: "row" }}
justifyContent="space-between"
alignItems={{ sm: "center" }}
spacing={1}
sx={{ mb: 2 }}
>
<Typography variant="h5">Dashboard</Typography>
<Button
variant="contained"
size="large"
startIcon={<AddIcon />}
onClick={() => navigate("/ncrs/new")}
>
New NCR
</Button>
</Stack>
<Card>
<Tabs
value={Math.min(tab, queues.length - 1)}
onChange={(_, v) => {
setTab(v);
setPage(1);
}}
variant="scrollable"
scrollButtons="auto"
sx={{ borderBottom: 1, borderColor: "divider" }}
>
{queues.map((q) => (
<Tab key={q.key} label={q.label} />
))}
</Tabs>
<CardContent>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ mb: 1 }}
alignItems={{ sm: "center" }}
>
{isNewRequests && (
<>
<TextField
size="small"
label="Filter by job number"
value={jobFilter}
onChange={(e) => {
setJobFilter(e.target.value);
setPage(1);
}}
/>
<TextField
size="small"
select
label="Disposition authority"
value={authorityFilter}
onChange={(e) => {
setAuthorityFilter(
e.target.value === "" ? "" : Number(e.target.value),
);
setPage(1);
}}
sx={{ minWidth: 220 }}
>
<MenuItem value="">All</MenuItem>
{(authorities.data ?? []).map((u) => (
<MenuItem key={u.id} value={u.id}>
{u.display_name}
</MenuItem>
))}
</TextField>
</>
)}
<Box sx={{ flexGrow: 1 }} />
<Button startIcon={<DownloadIcon />} onClick={exportCsv}>
Export CSV
</Button>
</Stack>
<QueueTable
items={queue.data?.items ?? []}
total={queue.data?.total ?? 0}
page={page}
pageSize={25}
onPageChange={setPage}
loading={queue.isLoading}
/>
</CardContent>
</Card>
</Box>
);
}

View File

@@ -0,0 +1,421 @@
import HistoryIcon from "@mui/icons-material/History";
import LockIcon from "@mui/icons-material/Lock";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import {
Alert,
Box,
Button,
Card,
CardContent,
CardHeader,
Chip,
CircularProgress,
Divider,
Grid,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tabs,
Typography,
} from "@mui/material";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { openBlob } from "../api/client";
import { useMe, useNcr, useNcrAudit } from "../api/hooks";
import type { NcrDetail } from "../api/types";
import { STAGE_LABELS } from "../api/types";
import { AttachmentSection } from "../components/AttachmentSection";
import { FieldRow } from "../components/FieldRow";
import { RichTextView } from "../components/RichTextView";
import { StageChip } from "../components/StageChip";
import { StageStepper } from "../components/StageStepper";
import { useToast } from "../components/Toast";
import {
CostingForm,
InitialDispositionForm,
InspectionForm,
OperationsForm,
ReopenDialog,
SecondaryDispositionForm,
} from "./StageForms";
function fmt(iso: string | null): string {
return iso ? new Date(iso).toLocaleString() : "—";
}
function money(v: string | null): string {
return v === null
? "—"
: Number(v).toLocaleString(undefined, { style: "currency", currency: "USD" });
}
function SectionCard({
title,
action,
children,
}: {
title: string;
action?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<Card sx={{ mb: 2 }}>
<CardHeader title={title} action={action} titleTypographyProps={{ variant: "h6" }} />
<Divider />
<CardContent>{children}</CardContent>
</Card>
);
}
function AuditTab({ ncrId }: { ncrId: number }) {
const audit = useNcrAudit(ncrId, true);
if (audit.isLoading) return <CircularProgress sx={{ m: 2 }} />;
if (audit.isError)
return <Alert severity="error">{(audit.error as Error).message}</Alert>;
const items = audit.data?.items ?? [];
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>When</TableCell>
<TableCell>Who</TableCell>
<TableCell>Action</TableCell>
<TableCell>Field</TableCell>
<TableCell>Before</TableCell>
<TableCell>After</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((a) => (
<TableRow key={a.id}>
<TableCell sx={{ whiteSpace: "nowrap" }}>{fmt(a.created_at)}</TableCell>
<TableCell>{a.user.display_name}</TableCell>
<TableCell>
<Chip size="small" label={a.action.replace(/_/g, " ")} />
{a.detail && (
<Typography variant="caption" display="block" color="text.secondary">
{a.detail}
</Typography>
)}
</TableCell>
<TableCell>{a.field_name ?? ""}</TableCell>
<TableCell sx={{ maxWidth: 220, overflowWrap: "anywhere" }}>
{a.old_value ?? ""}
</TableCell>
<TableCell sx={{ maxWidth: 220, overflowWrap: "anywhere" }}>
{a.new_value ?? ""}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function DetailBody({ ncr }: { ncr: NcrDetail }) {
const actions = ncr.available_actions;
const closed = ncr.stage === "closed";
return (
<>
{closed && (
<Alert icon={<LockIcon />} severity="info" sx={{ mb: 2 }}>
This NCR is closed and locked. Closed on {fmt(ncr.closed_at)} by{" "}
{ncr.closed_by?.display_name}. Only an Admin can reopen it.
</Alert>
)}
<SectionCard title="Request">
<Grid container spacing={2}>
<FieldRow label="Job Number">{ncr.job_number}</FieldRow>
<FieldRow label="Date">{new Date(ncr.created_at).toLocaleDateString()}</FieldRow>
<FieldRow label="Department">{ncr.department}</FieldRow>
<FieldRow label="Deviation Category">{ncr.deviation_category}</FieldRow>
<FieldRow label="Requester">{ncr.requester.display_name}</FieldRow>
<FieldRow label="Disposition Authority">
{ncr.disposition_authority.display_name}
</FieldRow>
{ncr.job_info && (
<>
<FieldRow label="Part (ERP)">
{[ncr.job_info.part_id, ncr.job_info.part_description]
.filter(Boolean)
.join(" — ")}
</FieldRow>
<FieldRow label="Customer (ERP)">{ncr.job_info.customer_name}</FieldRow>
<FieldRow label="WO Status (ERP)">{ncr.job_info.work_order_status}</FieldRow>
</>
)}
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary" display="block">
Deviation Detail
</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{ncr.deviation_detail}</Typography>
</Grid>
</Grid>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Attachments
</Typography>
<AttachmentSection
ncrId={ncr.id}
attachments={ncr.attachments}
canAdd={actions.includes("add_attachment")}
/>
</SectionCard>
<SectionCard title="Disposition">
{ncr.stage === "new_request" && !actions.includes("initial_disposition") ? (
<Typography color="text.secondary">
Awaiting initial disposition by {ncr.disposition_authority.display_name}.
</Typography>
) : (
<Grid container spacing={2} sx={{ mb: 1 }}>
<FieldRow label="QC Authority">{ncr.qc_authority}</FieldRow>
<FieldRow label="Work Order">{ncr.work_order}</FieldRow>
<FieldRow label="Secondary Review">
{ncr.secondary_review_needed === null
? "—"
: ncr.secondary_review_needed
? `Yes — ${ncr.secondary_authorities.map((u) => u.display_name).join(", ") || "unassigned"}`
: "No"}
</FieldRow>
{ncr.disposition_notes && (
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary" display="block">
Disposition Notes
</Typography>
<RichTextView html={ncr.disposition_notes} />
</Grid>
)}
</Grid>
)}
{actions.includes("initial_disposition") && (
<>
<Divider sx={{ my: 2 }}>
<Chip label="Initial Disposition — your action" color="primary" size="small" />
</Divider>
<InitialDispositionForm ncr={ncr} />
</>
)}
{actions.includes("secondary_disposition") && (
<>
<Divider sx={{ my: 2 }}>
<Chip label="Secondary Disposition — your action" color="primary" size="small" />
</Divider>
<SecondaryDispositionForm ncr={ncr} />
</>
)}
</SectionCard>
<SectionCard title="Operations">
<Grid container spacing={2}>
<FieldRow label="Operations Complete">
{ncr.operations_complete ? "Yes" : "Pending"}
</FieldRow>
<FieldRow label="Completed By">
{ncr.operations_completed_by?.display_name}
</FieldRow>
<FieldRow label="Completed At">{fmt(ncr.operations_completed_at)}</FieldRow>
</Grid>
{actions.includes("operations_complete") && (
<>
<Divider sx={{ my: 2 }}>
<Chip label="Operations — your action" color="primary" size="small" />
</Divider>
<OperationsForm ncr={ncr} />
</>
)}
</SectionCard>
<SectionCard title="QC Inspection">
<Grid container spacing={2}>
<FieldRow label="QC Approval">
{ncr.qc_approval === null ? "—" : ncr.qc_approval === "yes" ? "Yes" : "No"}
</FieldRow>
<FieldRow label="QC Closed">
{ncr.qc_closed
? `Yes — ${ncr.qc_closed_by?.display_name}, ${fmt(ncr.qc_closed_at)}`
: "Pending"}
</FieldRow>
{ncr.inspection_notes && (
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary" display="block">
Inspection Notes
</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{ncr.inspection_notes}</Typography>
</Grid>
)}
</Grid>
{actions.includes("inspection") && (
<>
<Divider sx={{ my: 2 }}>
<Chip label="QC Inspection — your action" color="primary" size="small" />
</Divider>
<InspectionForm ncr={ncr} />
</>
)}
</SectionCard>
<SectionCard title="Costing">
<Grid container spacing={2}>
<FieldRow label="Labor">{money(ncr.labor_cost)}</FieldRow>
<FieldRow label="Material">{money(ncr.material_cost)}</FieldRow>
<FieldRow label="Service">{money(ncr.service_cost)}</FieldRow>
<FieldRow label="Other">{money(ncr.other_cost)}</FieldRow>
<FieldRow label="Total">
<Typography component="span" fontWeight={700}>
{money(ncr.total_cost)}
</Typography>
</FieldRow>
<FieldRow label="Costed By">
{ncr.costing_completed_by
? `${ncr.costing_completed_by.display_name}, ${fmt(ncr.costing_completed_at)}`
: null}
</FieldRow>
</Grid>
{actions.includes("costing") && (
<>
<Divider sx={{ my: 2 }}>
<Chip label="Costing — your action" color="primary" size="small" />
</Divider>
<CostingForm ncr={ncr} />
</>
)}
</SectionCard>
<SectionCard title="Workflow History">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>When</TableCell>
<TableCell>Action</TableCell>
<TableCell>From</TableCell>
<TableCell>To</TableCell>
<TableCell>By</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ncr.transitions.map((t) => (
<TableRow key={t.id}>
<TableCell sx={{ whiteSpace: "nowrap" }}>{fmt(t.acted_at)}</TableCell>
<TableCell>
{t.action.replace(/_/g, " ")}
{t.note && (
<Typography variant="caption" display="block" color="text.secondary">
{t.note}
</Typography>
)}
</TableCell>
<TableCell>{t.from_stage ? STAGE_LABELS[t.from_stage] : "—"}</TableCell>
<TableCell>{STAGE_LABELS[t.to_stage]}</TableCell>
<TableCell>{t.acted_by.display_name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</>
);
}
export function NcrDetailPage() {
const { id } = useParams();
const ncrId = Number(id);
const ncrQuery = useNcr(Number.isFinite(ncrId) ? ncrId : undefined);
const me = useMe();
const { toast } = useToast();
const [tab, setTab] = useState(0);
const [reopenOpen, setReopenOpen] = useState(false);
if (ncrQuery.isLoading) {
return (
<Box sx={{ textAlign: "center", py: 8 }}>
<CircularProgress />
</Box>
);
}
if (ncrQuery.isError || !ncrQuery.data) {
return (
<Alert severity="error">
{(ncrQuery.error as Error | undefined)?.message ?? "NCR not found."}
</Alert>
);
}
const ncr = ncrQuery.data;
const canAudit =
ncr.available_actions.includes("view_audit") ||
(me.data?.roles.includes("admin") ?? false);
return (
<Box sx={{ maxWidth: 1100, mx: "auto" }}>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ md: "center" }}
spacing={1}
sx={{ mb: 1 }}
>
<Stack direction="row" spacing={1.5} alignItems="center">
<Typography variant="h5">{ncr.ncr_number}</Typography>
<StageChip stage={ncr.stage} size="medium" />
<Typography color="text.secondary" variant="body2">
{ncr.days_in_stage}d in stage
</Typography>
</Stack>
<Stack direction="row" spacing={1}>
<Button
startIcon={<PictureAsPdfIcon />}
variant="outlined"
onClick={() =>
void openBlob(`/api/ncrs/${ncr.id}/pdf`, `${ncr.ncr_number}.pdf`, "open").catch(
(e) => toast(e.message, "error"),
)
}
>
Print / Download PDF
</Button>
{ncr.available_actions.includes("reopen") && (
<Button
startIcon={<LockOpenIcon />}
variant="outlined"
color="warning"
onClick={() => setReopenOpen(true)}
>
Reopen
</Button>
)}
</Stack>
</Stack>
<StageStepper ncr={ncr} />
{canAudit ? (
<>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Details" />
<Tab icon={<HistoryIcon />} iconPosition="start" label="Audit History" />
</Tabs>
{tab === 0 ? (
<DetailBody ncr={ncr} />
) : (
<Card>
<CardContent>
<AuditTab ncrId={ncr.id} />
</CardContent>
</Card>
)}
</>
) : (
<DetailBody ncr={ncr} />
)}
<ReopenDialog ncr={ncr} open={reopenOpen} onClose={() => setReopenOpen(false)} />
</Box>
);
}

View File

@@ -0,0 +1,193 @@
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import {
Alert,
Box,
Button,
Card,
CardContent,
CircularProgress,
MenuItem,
Stack,
TextField,
Typography,
} from "@mui/material";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { useLookups } from "../api/hooks";
import type { NcrDetail, NcrMutationOut, UserOut } from "../api/types";
import { AttachmentSection } from "../components/AttachmentSection";
import { JobNumberField } from "../components/JobNumberField";
import { useToast } from "../components/Toast";
import { UserPicker } from "../components/UserPicker";
export function NewNcrPage() {
const lookups = useLookups();
const navigate = useNavigate();
const { warnings } = useToast();
const [jobNumber, setJobNumber] = useState("");
const [departmentId, setDepartmentId] = useState<number | "">("");
const [categoryId, setCategoryId] = useState<number | "">("");
const [authority, setAuthority] = useState<UserOut | null>(null);
const [detail, setDetail] = useState("");
const [created, setCreated] = useState<NcrDetail | null>(null);
const create = useMutation({
mutationFn: () =>
api<NcrMutationOut>("/api/ncrs", {
method: "POST",
body: {
job_number: jobNumber.trim(),
department_id: departmentId,
deviation_category_id: categoryId,
disposition_authority_id: authority?.id,
deviation_detail: detail.trim(),
},
}),
onSuccess: (data) => {
setCreated(data.ncr);
if (data.warnings.length) warnings(data.warnings);
window.scrollTo({ top: 0 });
},
});
const valid =
jobNumber.trim().length > 0 &&
departmentId !== "" &&
categoryId !== "" &&
authority !== null &&
detail.trim().length >= 5;
// ── Confirmation screen: prominent NCR number + immediate photo upload ────
if (created) {
return (
<Box sx={{ maxWidth: 720, mx: "auto" }}>
<Card>
<CardContent sx={{ textAlign: "center", py: 4 }}>
<CheckCircleIcon color="success" sx={{ fontSize: 56 }} />
<Typography variant="h6" sx={{ mt: 1 }}>
NCR submitted
</Typography>
<Typography
variant="h3"
color="primary"
sx={{ fontWeight: 800, my: 1, letterSpacing: 1 }}
>
{created.ncr_number}
</Typography>
<Typography color="text.secondary">
Job {created.job_number} · {created.department} ·{" "}
{created.deviation_category}
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mt: 0.5 }}>
{created.disposition_authority.display_name} has been notified for
initial disposition.
</Typography>
<Box sx={{ textAlign: "left", mt: 3 }}>
<Typography variant="subtitle2" gutterBottom>
Add photos / files now (optional)
</Typography>
<AttachmentSection
ncrId={created.id}
attachments={created.attachments}
canAdd
/>
</Box>
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 3 }}>
<Button variant="contained" onClick={() => navigate(`/ncrs/${created.id}`)}>
View NCR
</Button>
<Button
onClick={() => {
setCreated(null);
setJobNumber("");
setDetail("");
}}
>
Submit another
</Button>
</Stack>
</CardContent>
</Card>
</Box>
);
}
return (
<Box sx={{ maxWidth: 720, mx: "auto" }}>
<Typography variant="h5" gutterBottom>
New NCR Request
</Typography>
<Card>
<CardContent>
<Stack spacing={2}>
{create.isError && (
<Alert severity="error">{(create.error as Error).message}</Alert>
)}
<JobNumberField value={jobNumber} onChange={setJobNumber} required />
<TextField
select
required
label="Department"
value={departmentId}
onChange={(e) => setDepartmentId(Number(e.target.value))}
>
{(lookups.data?.departments ?? []).map((d) => (
<MenuItem key={d.id} value={d.id}>
{d.name}
</MenuItem>
))}
</TextField>
<TextField
select
required
label="Deviation Category"
value={categoryId}
onChange={(e) => setCategoryId(Number(e.target.value))}
>
{(lookups.data?.deviation_categories ?? []).map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</TextField>
<UserPicker
role="disposition_authority"
label="Disposition Authority"
value={authority}
onChange={(v) => setAuthority(v as UserOut | null)}
required
helperText="Who should review this nonconformance?"
/>
<TextField
label="Deviation Detail"
value={detail}
onChange={(e) => setDetail(e.target.value)}
required
multiline
minRows={4}
helperText="Describe what was found, where, and how many pieces are affected."
/>
<Typography variant="body2" color="text.secondary">
Photos and file attachments can be added on the next screen, right
after the NCR number is assigned.
</Typography>
<Button
variant="contained"
size="large"
disabled={!valid || create.isPending}
onClick={() => create.mutate()}
startIcon={create.isPending ? <CircularProgress size={18} /> : undefined}
>
Submit NCR
</Button>
</Stack>
</CardContent>
</Card>
</Box>
);
}

View File

@@ -0,0 +1,414 @@
import DownloadIcon from "@mui/icons-material/Download";
import TableChartIcon from "@mui/icons-material/TableChart";
import {
Box,
Button,
Card,
CardContent,
CardHeader,
CircularProgress,
Divider,
Grid,
MenuItem,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
ToggleButton,
Typography,
} from "@mui/material";
import { useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useLookups, useReportsSummary } from "../api/hooks";
/* Validated categorical palette (dataviz reference instance, light mode).
* Fixed slot order — color follows the entity: labor=1 material=2 service=3
* other=4. Aqua/yellow sit below 3:1 on white, so the cost chart ships a
* table view (relief rule). */
const SERIES = {
labor: "#2a78d6",
material: "#1baf7a",
service: "#eda100",
other: "#008300",
};
const SINGLE_HUE = "#2a78d6";
const GRID = "#eceff1";
const TICK = { fill: "#52514e", fontSize: 12 };
function money(n: number): string {
return n.toLocaleString(undefined, {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
}
function csvDownload(filename: string, rows: Record<string, unknown>[]): void {
if (rows.length === 0) return;
const headers = Object.keys(rows[0]);
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
const csv = [
headers.join(","),
...rows.map((r) => headers.map((h) => esc(r[h])).join(",")),
].join("\n");
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
function StatTile({ label, value }: { label: string; value: string }) {
return (
<Card sx={{ flexGrow: 1, minWidth: 150 }}>
<CardContent sx={{ py: 1.5, "&:last-child": { pb: 1.5 } }}>
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
<Typography variant="h5">{value}</Typography>
</CardContent>
</Card>
);
}
function ChartCard({
title,
subheader,
action,
children,
}: {
title: string;
subheader?: string;
action?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<Card sx={{ height: "100%" }}>
<CardHeader
title={title}
subheader={subheader}
action={action}
titleTypographyProps={{ variant: "subtitle1", fontWeight: 700 }}
subheaderTypographyProps={{ variant: "caption" }}
/>
<Divider />
<CardContent>{children}</CardContent>
</Card>
);
}
export function ReportsPage() {
const lookups = useLookups();
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [departmentId, setDepartmentId] = useState<number | "">("");
const [categoryId, setCategoryId] = useState<number | "">("");
const [costAsTable, setCostAsTable] = useState(false);
const summary = useReportsSummary({
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
department_id: departmentId || undefined,
category_id: categoryId || undefined,
});
const data = summary.data;
const costRows = (data?.cost_over_time ?? []).map((c) => ({
month: c.month,
Labor: Number(c.labor),
Material: Number(c.material),
Service: Number(c.service),
Other: Number(c.other),
Total: Number(c.total),
}));
return (
<Box>
<Typography variant="h5" gutterBottom>
Reports
</Typography>
{/* Filters — one row above the charts */}
<Card sx={{ mb: 2 }}>
<CardContent sx={{ py: 1.5, "&:last-child": { pb: 1.5 } }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<TextField
size="small"
type="date"
label="From"
InputLabelProps={{ shrink: true }}
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
<TextField
size="small"
type="date"
label="To"
InputLabelProps={{ shrink: true }}
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
<TextField
size="small"
select
label="Department"
value={departmentId}
onChange={(e) =>
setDepartmentId(e.target.value === "" ? "" : Number(e.target.value))
}
sx={{ minWidth: 180 }}
>
<MenuItem value="">All departments</MenuItem>
{(lookups.data?.departments ?? []).map((d) => (
<MenuItem key={d.id} value={d.id}>
{d.name}
</MenuItem>
))}
</TextField>
<TextField
size="small"
select
label="Category"
value={categoryId}
onChange={(e) =>
setCategoryId(e.target.value === "" ? "" : Number(e.target.value))
}
sx={{ minWidth: 180 }}
>
<MenuItem value="">All categories</MenuItem>
{(lookups.data?.deviation_categories ?? []).map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</TextField>
</Stack>
</CardContent>
</Card>
{summary.isLoading || !data ? (
<Box sx={{ textAlign: "center", py: 8 }}>
<CircularProgress />
</Box>
) : (
<>
<Stack direction="row" spacing={1.5} sx={{ mb: 2 }} flexWrap="wrap" useFlexGap>
<StatTile label="Total NCRs" value={String(data.total_ncrs)} />
<StatTile label="Open" value={String(data.open_ncrs)} />
<StatTile label="Closed" value={String(data.closed_ncrs)} />
<StatTile label="Cost of Nonconformance" value={money(Number(data.total_cost))} />
<StatTile
label="Avg End-to-End"
value={
data.end_to_end_avg_days !== null
? `${data.end_to_end_avg_days} days`
: "—"
}
/>
</Stack>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<ChartCard title="NCRs by Month">
<ResponsiveContainer width="100%" height={260}>
<BarChart data={data.by_month}>
<CartesianGrid stroke={GRID} vertical={false} />
<XAxis dataKey="month" tick={TICK} tickLine={false} />
<YAxis allowDecimals={false} tick={TICK} tickLine={false} axisLine={false} />
<Tooltip />
<Bar dataKey="count" name="NCRs" fill={SINGLE_HUE} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
</Grid>
<Grid item xs={12} md={6}>
<ChartCard
title="Cost of Nonconformance Over Time"
subheader="Closed NCRs, by month closed"
action={
<Stack direction="row" spacing={0.5}>
<ToggleButton
value="table"
size="small"
selected={costAsTable}
onChange={() => setCostAsTable(!costAsTable)}
title="Toggle table view"
>
<TableChartIcon fontSize="small" />
</ToggleButton>
<Button
size="small"
startIcon={<DownloadIcon />}
onClick={() => csvDownload("cost-of-nonconformance.csv", costRows)}
>
CSV
</Button>
</Stack>
}
>
{costAsTable ? (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Month</TableCell>
<TableCell align="right">Labor</TableCell>
<TableCell align="right">Material</TableCell>
<TableCell align="right">Service</TableCell>
<TableCell align="right">Other</TableCell>
<TableCell align="right">Total</TableCell>
</TableRow>
</TableHead>
<TableBody>
{costRows.map((r) => (
<TableRow key={r.month}>
<TableCell>{r.month}</TableCell>
<TableCell align="right">{money(r.Labor)}</TableCell>
<TableCell align="right">{money(r.Material)}</TableCell>
<TableCell align="right">{money(r.Service)}</TableCell>
<TableCell align="right">{money(r.Other)}</TableCell>
<TableCell align="right" sx={{ fontWeight: 700 }}>
{money(r.Total)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={costRows}>
<CartesianGrid stroke={GRID} vertical={false} />
<XAxis dataKey="month" tick={TICK} tickLine={false} />
<YAxis
tick={TICK}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => money(v)}
width={72}
/>
<Tooltip formatter={(v: number) => money(v)} />
<Legend />
{/* 2px surface gap between stacked segments via stroke */}
<Bar dataKey="Labor" stackId="cost" fill={SERIES.labor} stroke="#fff" strokeWidth={1} />
<Bar dataKey="Material" stackId="cost" fill={SERIES.material} stroke="#fff" strokeWidth={1} />
<Bar dataKey="Service" stackId="cost" fill={SERIES.service} stroke="#fff" strokeWidth={1} />
<Bar dataKey="Other" stackId="cost" fill={SERIES.other} stroke="#fff" strokeWidth={1} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</ChartCard>
</Grid>
<Grid item xs={12} md={6}>
<ChartCard title="NCRs by Department">
<ResponsiveContainer width="100%" height={Math.max(200, data.by_department.length * 34)}>
<BarChart data={data.by_department} layout="vertical">
<CartesianGrid stroke={GRID} horizontal={false} />
<XAxis type="number" allowDecimals={false} tick={TICK} tickLine={false} />
<YAxis type="category" dataKey="name" width={130} tick={TICK} tickLine={false} axisLine={false} />
<Tooltip />
<Bar dataKey="count" name="NCRs" fill={SINGLE_HUE} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
</Grid>
<Grid item xs={12} md={6}>
<ChartCard title="NCRs by Deviation Category">
<ResponsiveContainer width="100%" height={Math.max(200, data.by_category.length * 34)}>
<BarChart data={data.by_category} layout="vertical">
<CartesianGrid stroke={GRID} horizontal={false} />
<XAxis type="number" allowDecimals={false} tick={TICK} tickLine={false} />
<YAxis type="category" dataKey="name" width={160} tick={TICK} tickLine={false} axisLine={false} />
<Tooltip />
<Bar dataKey="count" name="NCRs" fill={SINGLE_HUE} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
</Grid>
<Grid item xs={12} md={6}>
<ChartCard title="Open NCR Aging" subheader="Days in current stage">
<ResponsiveContainer width="100%" height={240}>
<BarChart data={data.aging}>
<CartesianGrid stroke={GRID} vertical={false} />
<XAxis dataKey="bucket" tick={TICK} tickLine={false} />
<YAxis allowDecimals={false} tick={TICK} tickLine={false} axisLine={false} />
<Tooltip />
<Bar dataKey="count" name="Open NCRs" fill={SINGLE_HUE} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
</Grid>
<Grid item xs={12} md={6}>
<ChartCard title="Average Cycle Time per Stage" subheader="Days spent in each stage">
<ResponsiveContainer width="100%" height={240}>
<BarChart data={data.cycle_times} layout="vertical">
<CartesianGrid stroke={GRID} horizontal={false} />
<XAxis type="number" tick={TICK} tickLine={false} />
<YAxis type="category" dataKey="stage_label" width={150} tick={TICK} tickLine={false} axisLine={false} />
<Tooltip formatter={(v: number) => `${v} days`} />
<Bar dataKey="avg_days" name="Avg days" fill={SINGLE_HUE} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
</Grid>
<Grid item xs={12} md={6}>
<ChartCard
title="Top Job Numbers by NCR Count"
action={
<Button
size="small"
startIcon={<DownloadIcon />}
onClick={() => csvDownload("top-jobs.csv", data.top_jobs)}
>
CSV
</Button>
}
>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Job Number</TableCell>
<TableCell align="right">NCRs</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.top_jobs.map((j) => (
<TableRow key={j.job_number}>
<TableCell>{j.job_number}</TableCell>
<TableCell align="right">{j.count}</TableCell>
</TableRow>
))}
{data.top_jobs.length === 0 && (
<TableRow>
<TableCell colSpan={2}>
<Typography color="text.secondary">No data.</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ChartCard>
</Grid>
</Grid>
</>
)}
</Box>
);
}

View File

@@ -0,0 +1,172 @@
import DownloadIcon from "@mui/icons-material/Download";
import {
Box,
Button,
Card,
CardContent,
Grid,
MenuItem,
TextField,
Typography,
} from "@mui/material";
import { useState } from "react";
import { buildQuery, openBlob } from "../api/client";
import { useLookups, useQueue } from "../api/hooks";
import type { QueueFilters } from "../api/types";
import { STAGE_LABELS, STAGE_ORDER } from "../api/types";
import { QueueTable } from "../components/QueueTable";
import { useToast } from "../components/Toast";
export function SearchPage() {
const lookups = useLookups();
const { toast } = useToast();
const [page, setPage] = useState(1);
const [q, setQ] = useState("");
const [departmentId, setDepartmentId] = useState<number | "">("");
const [categoryId, setCategoryId] = useState<number | "">("");
const [stage, setStage] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const filters: QueueFilters = {
q: q || undefined,
department_id: departmentId || undefined,
category_id: categoryId || undefined,
stage: stage || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
};
const results = useQueue("all", filters, page);
const set = <T,>(setter: (v: T) => void) => (v: T) => {
setter(v);
setPage(1);
};
return (
<Box>
<Typography variant="h5" gutterBottom>
Search NCRs
</Typography>
<Card sx={{ mb: 2 }}>
<CardContent>
<Grid container spacing={1.5}>
<Grid item xs={12} sm={4} md={3}>
<TextField
fullWidth
size="small"
label="NCR # or Job #"
value={q}
onChange={(e) => set(setQ)(e.target.value)}
/>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<TextField
fullWidth
size="small"
select
label="Department"
value={departmentId}
onChange={(e) =>
set(setDepartmentId)(e.target.value === "" ? "" : Number(e.target.value))
}
>
<MenuItem value="">All</MenuItem>
{(lookups.data?.departments ?? []).map((d) => (
<MenuItem key={d.id} value={d.id}>
{d.name}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<TextField
fullWidth
size="small"
select
label="Category"
value={categoryId}
onChange={(e) =>
set(setCategoryId)(e.target.value === "" ? "" : Number(e.target.value))
}
>
<MenuItem value="">All</MenuItem>
{(lookups.data?.deviation_categories ?? []).map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={6} sm={4} md={2}>
<TextField
fullWidth
size="small"
select
label="Stage"
value={stage}
onChange={(e) => set(setStage)(e.target.value)}
>
<MenuItem value="">All</MenuItem>
{STAGE_ORDER.map((s) => (
<MenuItem key={s} value={s}>
{STAGE_LABELS[s]}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={6} sm={4} md={1.5}>
<TextField
fullWidth
size="small"
type="date"
label="From"
InputLabelProps={{ shrink: true }}
value={dateFrom}
onChange={(e) => set(setDateFrom)(e.target.value)}
/>
</Grid>
<Grid item xs={6} sm={4} md={1.5}>
<TextField
fullWidth
size="small"
type="date"
label="To"
InputLabelProps={{ shrink: true }}
value={dateTo}
onChange={(e) => set(setDateTo)(e.target.value)}
/>
</Grid>
</Grid>
</CardContent>
</Card>
<Card>
<CardContent>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
<Button
startIcon={<DownloadIcon />}
onClick={() =>
void openBlob(
`/api/ncrs/export.csv${buildQuery({ queue: "all", ...filters })}`,
"ncr-search.csv",
"download",
).catch((e) => toast(e.message, "error"))
}
>
Export CSV
</Button>
</Box>
<QueueTable
items={results.data?.items ?? []}
total={results.data?.total ?? 0}
page={page}
pageSize={25}
onPageChange={setPage}
loading={results.isLoading}
/>
</CardContent>
</Card>
</Box>
);
}

View File

@@ -0,0 +1,455 @@
/** Editable forms for the current workflow stage. Visibility is driven by the
* server's `available_actions`; the API re-enforces role + stage on submit. */
import SendIcon from "@mui/icons-material/Send";
import {
Alert,
Button,
Checkbox,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
InputAdornment,
MenuItem,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@mui/material";
import { useMemo, useState } from "react";
import { api } from "../api/client";
import { useNcrMutation } from "../api/hooks";
import type { NcrDetail, NcrMutationOut, UserOut } from "../api/types";
import { STAGE_LABELS } from "../api/types";
import { RichTextEditor } from "../components/RichTextEditor";
import { useToast } from "../components/Toast";
import { UserPicker } from "../components/UserPicker";
function useWarnToast() {
const { warnings, toast } = useToast();
return { warnings, toast };
}
// ── Initial Disposition ──────────────────────────────────────────────────────
export function InitialDispositionForm({ ncr }: { ncr: NcrDetail }) {
const { warnings, toast } = useWarnToast();
const [qcAuthority, setQcAuthority] = useState(ncr.qc_authority ?? "");
const [workOrder, setWorkOrder] = useState(ncr.work_order ?? "");
const [notes, setNotes] = useState(ncr.disposition_notes ?? "");
const [secondary, setSecondary] = useState(false);
const [assignees, setAssignees] = useState<UserOut[]>([]);
const mutation = useNcrMutation(
() =>
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/initial-disposition`, {
method: "POST",
body: {
qc_authority: qcAuthority || null,
work_order: workOrder || null,
disposition_notes: notes || null,
secondary_review_needed: secondary,
secondary_authority_ids: assignees.map((u) => u.id),
},
}),
warnings,
);
return (
<Stack spacing={2}>
{mutation.isError && (
<Alert severity="error">{(mutation.error as Error).message}</Alert>
)}
<TextField
label="QC Authority"
value={qcAuthority}
onChange={(e) => setQcAuthority(e.target.value)}
/>
<TextField
label="Work Order"
value={workOrder}
onChange={(e) => setWorkOrder(e.target.value)}
/>
<RichTextEditor label="Disposition Notes" value={notes} onChange={setNotes} />
<FormControlLabel
control={
<Checkbox checked={secondary} onChange={(e) => setSecondary(e.target.checked)} />
}
label="Secondary review needed"
/>
{secondary && (
<UserPicker
role="secondary_disposition_authority"
label="Notify These People"
multiple
value={assignees}
onChange={(v) => setAssignees((v as UserOut[]) ?? [])}
helperText="The selected secondary disposition authorities will see this NCR in their personal queue."
required
/>
)}
<Button
variant="contained"
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
disabled={mutation.isPending || (secondary && assignees.length === 0)}
onClick={() =>
mutation.mutate(undefined as never, {
onSuccess: () =>
toast(
secondary
? "Sent for secondary disposition review."
: "Released to Operations.",
),
})
}
>
{secondary ? "Send for Secondary Review" : "Release to Operations"}
</Button>
</Stack>
);
}
// ── Secondary Disposition ────────────────────────────────────────────────────
export function SecondaryDispositionForm({ ncr }: { ncr: NcrDetail }) {
const { warnings, toast } = useWarnToast();
const [qcAuthority, setQcAuthority] = useState(ncr.qc_authority ?? "");
const [workOrder, setWorkOrder] = useState(ncr.work_order ?? "");
const [notes, setNotes] = useState(ncr.disposition_notes ?? "");
const mutation = useNcrMutation(
(release: boolean) =>
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/secondary-disposition`, {
method: "POST",
body: {
qc_authority: qcAuthority || null,
work_order: workOrder || null,
disposition_notes: notes || null,
release,
},
}),
warnings,
);
return (
<Stack spacing={2}>
{mutation.isError && (
<Alert severity="error">{(mutation.error as Error).message}</Alert>
)}
<TextField
label="QC Authority"
value={qcAuthority}
onChange={(e) => setQcAuthority(e.target.value)}
/>
<TextField
label="Work Order"
value={workOrder}
onChange={(e) => setWorkOrder(e.target.value)}
/>
<RichTextEditor label="Disposition Notes" value={notes} onChange={setNotes} />
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
disabled={mutation.isPending}
onClick={() =>
mutation.mutate(false, { onSuccess: () => toast("Saved.") })
}
>
Save
</Button>
<Button
variant="contained"
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
disabled={mutation.isPending}
onClick={() =>
mutation.mutate(true, {
onSuccess: () => toast("Released to Operations."),
})
}
>
Release to Operations
</Button>
</Stack>
</Stack>
);
}
// ── Operations ───────────────────────────────────────────────────────────────
export function OperationsForm({ ncr }: { ncr: NcrDetail }) {
const { warnings, toast } = useWarnToast();
const mutation = useNcrMutation(
() =>
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/operations-complete`, {
method: "POST",
}),
warnings,
);
return (
<Stack spacing={1}>
{mutation.isError && (
<Alert severity="error">{(mutation.error as Error).message}</Alert>
)}
<Typography color="text.secondary" variant="body2">
Review the disposition above, complete the rework/repair, then mark
operations complete to send this NCR to QC Inspection.
</Typography>
<Button
variant="contained"
disabled={mutation.isPending}
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
onClick={() =>
mutation.mutate(undefined as never, {
onSuccess: () => toast("Operations complete — sent to QC Inspection."),
})
}
>
Mark Operations Complete
</Button>
</Stack>
);
}
// ── QC Inspection ────────────────────────────────────────────────────────────
export function InspectionForm({ ncr }: { ncr: NcrDetail }) {
const { warnings, toast } = useWarnToast();
const [approval, setApproval] = useState<"yes" | "no" | null>(ncr.qc_approval);
const [notes, setNotes] = useState(ncr.inspection_notes ?? "");
const [close, setClose] = useState(false);
const mutation = useNcrMutation(
() =>
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/inspection`, {
method: "POST",
body: {
qc_approval: approval,
inspection_notes: notes || null,
qc_closed: close,
},
}),
warnings,
);
return (
<Stack spacing={2}>
{mutation.isError && (
<Alert severity="error">{(mutation.error as Error).message}</Alert>
)}
<Stack direction="row" spacing={2} alignItems="center">
<Typography>QC Approval:</Typography>
<ToggleButtonGroup
exclusive
value={approval}
onChange={(_, v) => setApproval(v)}
size="small"
>
<ToggleButton value="yes" color="success">
Yes
</ToggleButton>
<ToggleButton value="no" color="error">
No
</ToggleButton>
</ToggleButtonGroup>
</Stack>
<TextField
label="Inspection Notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
multiline
minRows={3}
/>
<FormControlLabel
control={<Checkbox checked={close} onChange={(e) => setClose(e.target.checked)} />}
label="QC Closed — send to Costing (inspection can no longer be edited)"
/>
<Button
variant="contained"
disabled={mutation.isPending}
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
onClick={() =>
mutation.mutate(undefined as never, {
onSuccess: () =>
toast(close ? "QC closed — sent to Costing." : "Inspection saved."),
})
}
>
{close ? "Save & Close QC" : "Save Inspection"}
</Button>
</Stack>
);
}
// ── Costing ──────────────────────────────────────────────────────────────────
function CostField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<TextField
label={label}
value={value}
onChange={(e) => {
const v = e.target.value;
if (/^\d*\.?\d{0,2}$/.test(v)) onChange(v);
}}
inputProps={{ inputMode: "decimal" }}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
sx={{ maxWidth: 220 }}
/>
);
}
export function CostingForm({ ncr }: { ncr: NcrDetail }) {
const { warnings, toast } = useWarnToast();
const [labor, setLabor] = useState(ncr.labor_cost ?? "");
const [material, setMaterial] = useState(ncr.material_cost ?? "");
const [service, setService] = useState(ncr.service_cost ?? "");
const [other, setOther] = useState(ncr.other_cost ?? "");
const total = useMemo(() => {
const sum =
(parseFloat(labor) || 0) +
(parseFloat(material) || 0) +
(parseFloat(service) || 0) +
(parseFloat(other) || 0);
return sum.toLocaleString(undefined, {
style: "currency",
currency: "USD",
});
}, [labor, material, service, other]);
const allSet = [labor, material, service, other].every((v) => v !== "");
const mutation = useNcrMutation(
() =>
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/costing`, {
method: "POST",
body: {
labor_cost: labor || "0",
material_cost: material || "0",
service_cost: service || "0",
other_cost: other || "0",
},
}),
warnings,
);
return (
<Stack spacing={2}>
{mutation.isError && (
<Alert severity="error">{(mutation.error as Error).message}</Alert>
)}
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<CostField label="Labor Cost" value={labor} onChange={setLabor} />
<CostField label="Material Cost" value={material} onChange={setMaterial} />
<CostField label="Service Cost" value={service} onChange={setService} />
<CostField label="Other Cost" value={other} onChange={setOther} />
</Stack>
<Typography variant="h6">Total: {total}</Typography>
<Alert severity="info">
Saving costs completes the workflow and closes this NCR. A closed NCR is
locked; only an Admin can reopen it.
</Alert>
<Button
variant="contained"
disabled={!allSet || mutation.isPending}
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
onClick={() =>
mutation.mutate(undefined as never, {
onSuccess: () => toast("Costing complete — NCR closed."),
})
}
>
Save Costs & Close NCR
</Button>
</Stack>
);
}
// ── Admin Reopen ─────────────────────────────────────────────────────────────
const REOPEN_TARGETS = [
"new_request",
"secondary_disposition",
"operations",
"qc_inspection",
"costing",
] as const;
export function ReopenDialog({
ncr,
open,
onClose,
}: {
ncr: NcrDetail;
open: boolean;
onClose: () => void;
}) {
const { warnings, toast } = useWarnToast();
const [target, setTarget] = useState<string>("costing");
const [reason, setReason] = useState("");
const mutation = useNcrMutation(
() =>
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/reopen`, {
method: "POST",
body: { to_stage: target, reason: reason.trim() },
}),
warnings,
);
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle>Reopen {ncr.ncr_number}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{mutation.isError && (
<Alert severity="error">{(mutation.error as Error).message}</Alert>
)}
<TextField
select
label="Reopen to stage"
value={target}
onChange={(e) => setTarget(e.target.value)}
>
{REOPEN_TARGETS.map((s) => (
<MenuItem key={s} value={s}>
{STAGE_LABELS[s]}
</MenuItem>
))}
</TextField>
<TextField
label="Reason (required, recorded in the audit trail)"
value={reason}
onChange={(e) => setReason(e.target.value)}
multiline
minRows={2}
required
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
color="warning"
disabled={reason.trim().length < 5 || mutation.isPending}
onClick={() =>
mutation.mutate(undefined as never, {
onSuccess: () => {
toast("NCR reopened.");
onClose();
},
})
}
>
Reopen NCR
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,105 @@
import {
Card,
CardContent,
Chip,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TablePagination,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api, buildQuery } from "../../api/client";
import type { AuditEntry } from "../../api/types";
interface GlobalAudit {
items: AuditEntry[];
total: number;
page: number;
page_size: number;
}
export function AdminAuditPage() {
const [page, setPage] = useState(1);
const [ncrNumber, setNcrNumber] = useState("");
const audit = useQuery({
queryKey: ["admin-audit", page, ncrNumber],
queryFn: () =>
api<GlobalAudit>(
`/api/admin/audit${buildQuery({ page, page_size: 50, ncr_number: ncrNumber })}`,
),
placeholderData: (prev) => prev,
});
return (
<Card>
<CardContent>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<TextField
size="small"
label="Filter by NCR number"
value={ncrNumber}
onChange={(e) => {
setNcrNumber(e.target.value);
setPage(1);
}}
/>
<Typography variant="body2" color="text.secondary">
Immutable system-wide audit trail (field-level before/after values).
</Typography>
</Stack>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>When</TableCell>
<TableCell>Who</TableCell>
<TableCell>Action</TableCell>
<TableCell>Field</TableCell>
<TableCell>Before</TableCell>
<TableCell>After</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(audit.data?.items ?? []).map((a) => (
<TableRow key={a.id}>
<TableCell sx={{ whiteSpace: "nowrap" }}>
{new Date(a.created_at).toLocaleString()}
</TableCell>
<TableCell>{a.user.display_name}</TableCell>
<TableCell>
<Chip size="small" label={a.action.replace(/_/g, " ")} />
{a.detail && (
<Typography variant="caption" display="block" color="text.secondary">
{a.detail}
</Typography>
)}
</TableCell>
<TableCell>{a.field_name ?? ""}</TableCell>
<TableCell sx={{ maxWidth: 200, overflowWrap: "anywhere" }}>
{a.old_value ?? ""}
</TableCell>
<TableCell sx={{ maxWidth: 200, overflowWrap: "anywhere" }}>
{a.new_value ?? ""}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TablePagination
component="div"
count={audit.data?.total ?? 0}
page={page - 1}
onPageChange={(_, p) => setPage(p + 1)}
rowsPerPage={50}
rowsPerPageOptions={[50]}
/>
</CardContent>
</Card>
);
}

View File

@@ -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<NamedLookup[]>(endpoint),
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["admin-lookup", endpoint] });
qc.invalidateQueries({ queryKey: ["lookups"] });
};
const create = useMutation({
mutationFn: () => api<NamedLookup>(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<NamedLookup>(`${endpoint}/${id}`, { method: "PATCH", body: { is_active } }),
onSuccess: invalidate,
onError: (e) => toast(e.message, "error"),
});
return (
<Card>
<CardHeader title={title} subheader={helper} />
<Divider />
<CardContent>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<TextField
size="small"
label={`New ${title.toLowerCase().replace(/s$/, "")}`}
value={newName}
onChange={(e) => setNewName(e.target.value)}
fullWidth
/>
<Button
variant="contained"
startIcon={<AddIcon />}
disabled={!newName.trim() || create.isPending}
onClick={() => create.mutate()}
>
Add
</Button>
</Stack>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell align="right">Active</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(items.data ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell
sx={{ color: item.is_active ? "inherit" : "text.disabled" }}
>
{item.name}
</TableCell>
<TableCell align="right">
<Switch
size="small"
checked={item.is_active}
onChange={(e) =>
patch.mutate({ id: item.id, is_active: e.target.checked })
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
export function AdminListsPage() {
return (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Values are never deleted deactivating hides them from new NCRs while
existing records keep their history.
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<LookupManager
title="Departments"
endpoint="/api/admin/departments"
helper="Shown in the Department dropdown on new NCRs"
/>
</Grid>
<Grid item xs={12} md={6}>
<LookupManager
title="Deviation Categories"
endpoint="/api/admin/categories"
helper="Shown in the Deviation Category dropdown on new NCRs"
/>
</Grid>
</Grid>
</>
);
}

View File

@@ -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 <Alert severity="warning">The Admin area requires the Admin role.</Alert>;
}
const current = TABS.findIndex((t) =>
location.pathname.includes(`/admin/${t.path}`),
);
return (
<Box>
<Typography variant="h5" gutterBottom>
Administration
</Typography>
<Tabs
value={current === -1 ? 0 : current}
onChange={(_, v) => navigate(`/admin/${TABS[v].path}`)}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 2, borderBottom: 1, borderColor: "divider" }}
>
{TABS.map((t) => (
<Tab key={t.path} label={t.label} />
))}
</Tabs>
<Routes>
<Route path="users" element={<AdminUsersPage />} />
<Route path="lists" element={<AdminListsPage />} />
<Route path="settings" element={<AdminSettingsPage />} />
<Route path="audit" element={<AdminAuditPage />} />
<Route path="*" element={<Navigate to="users" replace />} />
</Routes>
</Box>
);
}

View File

@@ -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<Settings>("/api/admin/settings"),
});
const save = useMutation({
mutationFn: (notifications_enabled: boolean) =>
api<Settings>("/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 (
<Card sx={{ maxWidth: 640 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Notifications
</Typography>
<FormControlLabel
control={
<Switch
checked={settings.data?.notifications_enabled ?? false}
disabled={settings.isLoading || save.isPending}
onChange={(e) => save.mutate(e.target.checked)}
/>
}
label="Send stage-transition emails (Microsoft Graph, from the acting user's mailbox)"
/>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
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.
</Typography>
</CardContent>
</Card>
);
}

View File

@@ -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<Set<Role>>(new Set(user.roles));
const save = useMutation({
mutationFn: () =>
api<UserOut>(`/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 (
<Dialog open onClose={onClose} fullWidth maxWidth="xs">
<DialogTitle>Roles {user.display_name}</DialogTitle>
<DialogContent>
<Stack>
{ROLES.map((role) => (
<FormControlLabel
key={role}
control={
<Checkbox
checked={roles.has(role)}
onChange={(e) => {
const next = new Set(roles);
if (e.target.checked) next.add(role);
else next.delete(role);
setRoles(next);
}}
/>
}
label={ROLE_LABELS[role]}
/>
))}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={() => save.mutate()} disabled={save.isPending}>
Save
</Button>
</DialogActions>
</Dialog>
);
}
export function AdminUsersPage() {
const qc = useQueryClient();
const { toast } = useToast();
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<UserOut | null>(null);
const users = useQuery({
queryKey: ["admin-users", search],
queryFn: () =>
api<UserOut[]>(`/api/admin/users${search ? `?search=${encodeURIComponent(search)}` : ""}`),
});
const setActive = useMutation({
mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) =>
api<UserOut>(`/api/admin/users/${id}/active`, {
method: "PUT",
body: { is_active },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
onError: (e) => toast(e.message, "error"),
});
return (
<Card>
<CardContent>
<Stack direction="row" spacing={1} sx={{ mb: 2 }} alignItems="center">
<TextField
size="small"
label="Search users"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Typography variant="body2" color="text.secondary">
Users are auto-provisioned on first sign-in; assign workflow roles here.
</Typography>
</Stack>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Roles</TableCell>
<TableCell>Active</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{(users.data ?? []).map((u) => (
<TableRow key={u.id} hover>
<TableCell>{u.display_name}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{u.roles.map((r) => (
<Chip
key={r}
size="small"
label={ROLE_LABELS[r] ?? r}
color={r === "admin" ? "secondary" : "default"}
/>
))}
</Stack>
</TableCell>
<TableCell>
<Switch
size="small"
checked={u.is_active}
onChange={(e) =>
setActive.mutate({ id: u.id, is_active: e.target.checked })
}
/>
</TableCell>
<TableCell>
<Button size="small" onClick={() => setEditing(u)}>
Edit Roles
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
{editing && <RolesDialog user={editing} onClose={() => setEditing(null)} />}
</Card>
);
}

36
frontend/src/theme.ts Normal file
View File

@@ -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<string, { bg: string; fg: string }> = {
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" },
};

21
frontend/tsconfig.json Normal file
View File

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

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

2
frontend/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

25
frontend/vite.config.js Normal file
View File

@@ -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"],
},
},
},
},
});

26
frontend/vite.config.ts Normal file
View File

@@ -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"],
},
},
},
},
});