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:
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci 2>/dev/null || npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY docker-entrypoint.d/50-config.sh /docker-entrypoint.d/50-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/50-config.sh
|
||||
EXPOSE 80
|
||||
20
frontend/docker-entrypoint.d/50-config.sh
Normal file
20
frontend/docker-entrypoint.d/50-config.sh
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
# Generates the SPA's runtime configuration from container environment
|
||||
# variables (nginx image runs every /docker-entrypoint.d/*.sh on start).
|
||||
set -e
|
||||
|
||||
API_SCOPE="${ENTRA_API_SCOPE}"
|
||||
if [ -z "$API_SCOPE" ] && [ -n "$ENTRA_CLIENT_ID" ]; then
|
||||
API_SCOPE="api://${ENTRA_CLIENT_ID}/access_as_user"
|
||||
fi
|
||||
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
window.__APP_CONFIG__ = {
|
||||
authMode: "${AUTH_MODE:-entra}",
|
||||
tenantId: "${ENTRA_TENANT_ID}",
|
||||
clientId: "${ENTRA_CLIENT_ID}",
|
||||
apiScope: "${API_SCOPE}"
|
||||
};
|
||||
EOF
|
||||
|
||||
echo "[frontend] config.js generated (authMode=${AUTH_MODE:-entra})"
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1a5fb4" />
|
||||
<title>PESCO NCR</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='18' fill='%231a5fb4'/><text x='50' y='68' font-size='52' text-anchor='middle' fill='white' font-family='Arial' font-weight='bold'>N</text></svg>" />
|
||||
<!-- Runtime configuration, generated from env by the nginx entrypoint -->
|
||||
<script src="/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
frontend/nginx.conf
Normal file
33
frontend/nginx.conf
Normal file
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Attachment uploads flow through this proxy; keep in sync with MAX_UPLOAD_MB.
|
||||
client_max_body_size 50m;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location = /config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
3671
frontend/package-lock.json
generated
Normal file
3671
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "pesco-ncr-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^3.26.1",
|
||||
"@azure/msal-react": "^2.1.1",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^5.16.7",
|
||||
"@mui/material": "^5.16.7",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tiptap/extension-link": "^2.9.1",
|
||||
"@tiptap/react": "^2.9.1",
|
||||
"@tiptap/starter-kit": "^2.9.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recharts": "^2.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "~5.5.4",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
8
frontend/public/config.js
Normal file
8
frontend/public/config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Local development defaults. In Docker this file is REPLACED at container
|
||||
// start by docker-entrypoint.d/50-config.sh using the real environment.
|
||||
window.__APP_CONFIG__ = {
|
||||
authMode: "dev",
|
||||
tenantId: "",
|
||||
clientId: "",
|
||||
apiScope: "",
|
||||
};
|
||||
24
frontend/src/App.tsx
Normal file
24
frontend/src/App.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { AdminPage } from "./pages/admin/AdminPage";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { NcrDetailPage } from "./pages/NcrDetailPage";
|
||||
import { NewNcrPage } from "./pages/NewNcrPage";
|
||||
import { ReportsPage } from "./pages/ReportsPage";
|
||||
import { SearchPage } from "./pages/SearchPage";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/ncrs/new" element={<NewNcrPage />} />
|
||||
<Route path="/ncrs/:id" element={<NcrDetailPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/admin/*" element={<AdminPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
135
frontend/src/api/client.ts
Normal file
135
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
InteractionRequiredAuthError,
|
||||
PublicClientApplication,
|
||||
} from "@azure/msal-browser";
|
||||
import { config } from "../config";
|
||||
|
||||
export const msalInstance =
|
||||
config.authMode === "entra"
|
||||
? new PublicClientApplication({
|
||||
auth: {
|
||||
clientId: config.clientId,
|
||||
authority: `https://login.microsoftonline.com/${config.tenantId}`,
|
||||
redirectUri: window.location.origin,
|
||||
postLogoutRedirectUri: window.location.origin,
|
||||
},
|
||||
cache: { cacheLocation: "sessionStorage" },
|
||||
})
|
||||
: null;
|
||||
|
||||
const DEV_USER_KEY = "pesco-ncr-dev-user";
|
||||
|
||||
export function getDevUser(): string {
|
||||
return localStorage.getItem(DEV_USER_KEY) || "admin@pescoinc.biz";
|
||||
}
|
||||
|
||||
export function setDevUser(email: string): void {
|
||||
localStorage.setItem(DEV_USER_KEY, email);
|
||||
}
|
||||
|
||||
async function authHeaders(): Promise<Record<string, string>> {
|
||||
if (config.authMode === "dev") {
|
||||
return { "X-Dev-User": getDevUser() };
|
||||
}
|
||||
const instance = msalInstance!;
|
||||
const account = instance.getActiveAccount() ?? instance.getAllAccounts()[0];
|
||||
if (!account) {
|
||||
await instance.loginRedirect({ scopes: [config.apiScope] });
|
||||
throw new Error("Redirecting to sign in…");
|
||||
}
|
||||
try {
|
||||
const result = await instance.acquireTokenSilent({
|
||||
scopes: [config.apiScope],
|
||||
account,
|
||||
});
|
||||
return { Authorization: `Bearer ${result.accessToken}` };
|
||||
} catch (err) {
|
||||
if (err instanceof InteractionRequiredAuthError) {
|
||||
await instance.acquireTokenRedirect({ scopes: [config.apiScope], account });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, detail: string) {
|
||||
super(detail);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(resp: Response): Promise<ApiError> {
|
||||
let detail = `Request failed (${resp.status})`;
|
||||
try {
|
||||
const body = await resp.json();
|
||||
if (typeof body.detail === "string") detail = body.detail;
|
||||
else if (Array.isArray(body.detail) && body.detail[0]?.msg)
|
||||
detail = body.detail
|
||||
.map((d: { loc?: unknown[]; msg: string }) => d.msg)
|
||||
.join("; ");
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
return new ApiError(resp.status, detail);
|
||||
}
|
||||
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
options: { method?: string; body?: unknown } = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = await authHeaders();
|
||||
const init: RequestInit = { method: options.method ?? "GET", headers };
|
||||
if (options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(options.body);
|
||||
}
|
||||
const resp = await fetch(path, init);
|
||||
if (!resp.ok) throw await parseError(resp);
|
||||
if (resp.status === 204) return undefined as T;
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
export async function apiUpload<T>(path: string, form: FormData): Promise<T> {
|
||||
const headers = await authHeaders();
|
||||
const resp = await fetch(path, { method: "POST", headers, body: form });
|
||||
if (!resp.ok) throw await parseError(resp);
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
export async function apiBlob(path: string): Promise<Blob> {
|
||||
const headers = await authHeaders();
|
||||
const resp = await fetch(path, { headers });
|
||||
if (!resp.ok) throw await parseError(resp);
|
||||
return resp.blob();
|
||||
}
|
||||
|
||||
/** Fetch a protected file and hand it to the browser (download or new tab). */
|
||||
export async function openBlob(
|
||||
path: string,
|
||||
filename: string,
|
||||
mode: "download" | "open",
|
||||
): Promise<void> {
|
||||
const blob = await apiBlob(path);
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (mode === "open") {
|
||||
window.open(url, "_blank");
|
||||
} else {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
export function buildQuery(params: Record<string, unknown>): string {
|
||||
const q = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
q.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const s = q.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
121
frontend/src/api/hooks.ts
Normal file
121
frontend/src/api/hooks.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { api, apiUpload, buildQuery } from "./client";
|
||||
import type {
|
||||
AttachmentOut,
|
||||
AuditListOut,
|
||||
JobLookupOut,
|
||||
LookupsOut,
|
||||
MeOut,
|
||||
NcrDetail,
|
||||
NcrListOut,
|
||||
NcrMutationOut,
|
||||
QueueFilters,
|
||||
ReportsSummary,
|
||||
UserOut,
|
||||
} from "./types";
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api<MeOut>("/api/me"),
|
||||
staleTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLookups() {
|
||||
return useQuery({
|
||||
queryKey: ["lookups"],
|
||||
queryFn: () => api<LookupsOut>("/api/lookups"),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsersByRole(role: string) {
|
||||
return useQuery({
|
||||
queryKey: ["users", role],
|
||||
queryFn: () => api<UserOut[]>(`/api/users?role=${role}`),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useQueue(queue: string, filters: QueueFilters, page: number, pageSize = 25) {
|
||||
return useQuery({
|
||||
queryKey: ["ncrs", queue, filters, page, pageSize],
|
||||
queryFn: () =>
|
||||
api<NcrListOut>(
|
||||
`/api/ncrs${buildQuery({ queue, page, page_size: pageSize, ...filters })}`,
|
||||
),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNcr(id: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["ncr", id],
|
||||
queryFn: () => api<NcrDetail>(`/api/ncrs/${id}`),
|
||||
enabled: id !== undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNcrAudit(id: number, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["ncr-audit", id],
|
||||
queryFn: () => api<AuditListOut>(`/api/ncrs/${id}/audit`),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useJobLookup(jobNumber: string) {
|
||||
return useQuery({
|
||||
queryKey: ["job-lookup", jobNumber],
|
||||
queryFn: () =>
|
||||
api<JobLookupOut>(`/api/jobs/${encodeURIComponent(jobNumber)}/lookup`),
|
||||
enabled: jobNumber.trim().length > 2,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReportsSummary(filters: Record<string, unknown>) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", filters],
|
||||
queryFn: () =>
|
||||
api<ReportsSummary>(`/api/reports/summary${buildQuery(filters)}`),
|
||||
});
|
||||
}
|
||||
|
||||
/** Shared invalidation + warning plumbing for every NCR mutation. */
|
||||
export function useNcrMutation<TVars>(
|
||||
mutationFn: (vars: TVars) => Promise<NcrMutationOut>,
|
||||
onWarnings?: (warnings: string[]) => void,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(["ncr", data.ncr.id], data.ncr);
|
||||
qc.invalidateQueries({ queryKey: ["ncrs"] });
|
||||
qc.invalidateQueries({ queryKey: ["ncr-audit", data.ncr.id] });
|
||||
if (data.warnings.length && onWarnings) onWarnings(data.warnings);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadAttachments(ncrId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (files: File[]) => {
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append("files", f, f.name);
|
||||
return apiUpload<AttachmentOut[]>(`/api/ncrs/${ncrId}/attachments`, form);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["ncr", ncrId] });
|
||||
qc.invalidateQueries({ queryKey: ["ncr-audit", ncrId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
246
frontend/src/api/types.ts
Normal file
246
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
export type StageValue =
|
||||
| "new_request"
|
||||
| "secondary_disposition"
|
||||
| "operations"
|
||||
| "qc_inspection"
|
||||
| "costing"
|
||||
| "closed";
|
||||
|
||||
export const STAGE_LABELS: Record<StageValue, string> = {
|
||||
new_request: "New Request",
|
||||
secondary_disposition: "Secondary Disposition",
|
||||
operations: "Operations",
|
||||
qc_inspection: "QC Inspection",
|
||||
costing: "Costing",
|
||||
closed: "Closed",
|
||||
};
|
||||
|
||||
export const STAGE_ORDER: StageValue[] = [
|
||||
"new_request",
|
||||
"secondary_disposition",
|
||||
"operations",
|
||||
"qc_inspection",
|
||||
"costing",
|
||||
"closed",
|
||||
];
|
||||
|
||||
export const ROLES = [
|
||||
"requester",
|
||||
"disposition_authority",
|
||||
"secondary_disposition_authority",
|
||||
"operations",
|
||||
"qc_inspector",
|
||||
"costing",
|
||||
"admin",
|
||||
] as const;
|
||||
export type Role = (typeof ROLES)[number];
|
||||
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
requester: "Requester",
|
||||
disposition_authority: "Disposition Authority",
|
||||
secondary_disposition_authority: "Secondary Disposition Authority",
|
||||
operations: "Operations",
|
||||
qc_inspector: "QC Inspector",
|
||||
costing: "Costing",
|
||||
admin: "Admin",
|
||||
};
|
||||
|
||||
export interface UserRef {
|
||||
id: number;
|
||||
display_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface UserOut extends UserRef {
|
||||
employee_id: string | null;
|
||||
is_active: boolean;
|
||||
roles: Role[];
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
export interface MeOut extends UserOut {
|
||||
auth_mode: "entra" | "dev";
|
||||
}
|
||||
|
||||
export interface NamedLookup {
|
||||
id: number;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface LookupsOut {
|
||||
departments: NamedLookup[];
|
||||
deviation_categories: NamedLookup[];
|
||||
}
|
||||
|
||||
export interface AttachmentOut {
|
||||
id: number;
|
||||
original_filename: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
is_image: boolean;
|
||||
uploaded_at: string;
|
||||
uploaded_by: UserRef;
|
||||
}
|
||||
|
||||
export interface TransitionOut {
|
||||
id: number;
|
||||
from_stage: StageValue | null;
|
||||
to_stage: StageValue;
|
||||
action: string;
|
||||
acted_at: string;
|
||||
acted_by: UserRef;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface JobInfoOut {
|
||||
part_id: string | null;
|
||||
part_description: string | null;
|
||||
customer_name: string | null;
|
||||
work_order_status: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface NcrListItem {
|
||||
id: number;
|
||||
ncr_number: string;
|
||||
job_number: string;
|
||||
department: string;
|
||||
deviation_category: string;
|
||||
requester: string;
|
||||
disposition_authority: string;
|
||||
stage: StageValue;
|
||||
stage_label: string;
|
||||
days_in_stage: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NcrListOut {
|
||||
items: NcrListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export type NcrAction =
|
||||
| "initial_disposition"
|
||||
| "secondary_disposition"
|
||||
| "operations_complete"
|
||||
| "inspection"
|
||||
| "costing"
|
||||
| "reopen"
|
||||
| "add_attachment"
|
||||
| "view_audit";
|
||||
|
||||
export interface NcrDetail {
|
||||
id: number;
|
||||
ncr_number: string;
|
||||
job_number: string;
|
||||
created_at: string;
|
||||
stage: StageValue;
|
||||
stage_label: string;
|
||||
stage_entered_at: string;
|
||||
days_in_stage: number;
|
||||
department: string;
|
||||
department_id: number;
|
||||
deviation_category: string;
|
||||
deviation_category_id: number;
|
||||
deviation_detail: string;
|
||||
requester: UserRef;
|
||||
disposition_authority: UserRef;
|
||||
qc_authority: string | null;
|
||||
work_order: string | null;
|
||||
disposition_notes: string | null;
|
||||
secondary_review_needed: boolean | null;
|
||||
secondary_authorities: UserRef[];
|
||||
operations_complete: boolean;
|
||||
operations_completed_at: string | null;
|
||||
operations_completed_by: UserRef | null;
|
||||
qc_approval: "yes" | "no" | null;
|
||||
inspection_notes: string | null;
|
||||
qc_closed: boolean;
|
||||
qc_closed_at: string | null;
|
||||
qc_closed_by: UserRef | null;
|
||||
labor_cost: string | null;
|
||||
material_cost: string | null;
|
||||
service_cost: string | null;
|
||||
other_cost: string | null;
|
||||
total_cost: string | null;
|
||||
costing_completed_at: string | null;
|
||||
costing_completed_by: UserRef | null;
|
||||
closed_at: string | null;
|
||||
closed_by: UserRef | null;
|
||||
job_info: JobInfoOut | null;
|
||||
attachments: AttachmentOut[];
|
||||
transitions: TransitionOut[];
|
||||
available_actions: NcrAction[];
|
||||
}
|
||||
|
||||
export interface NcrMutationOut {
|
||||
ncr: NcrDetail;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
created_at: string;
|
||||
user: UserRef;
|
||||
action: string;
|
||||
field_name: string | null;
|
||||
old_value: string | null;
|
||||
new_value: string | null;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface AuditListOut {
|
||||
items: AuditEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface QueueFilters {
|
||||
q?: string;
|
||||
job_number?: string;
|
||||
department_id?: number;
|
||||
category_id?: number;
|
||||
stage?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
disposition_authority_id?: number;
|
||||
}
|
||||
|
||||
export interface ReportsSummary {
|
||||
total_ncrs: number;
|
||||
open_ncrs: number;
|
||||
closed_ncrs: number;
|
||||
total_cost: string;
|
||||
by_department: { name: string; count: number }[];
|
||||
by_category: { name: string; count: number }[];
|
||||
by_month: { month: string; count: number }[];
|
||||
cost_over_time: {
|
||||
month: string;
|
||||
labor: string;
|
||||
material: string;
|
||||
service: string;
|
||||
other: string;
|
||||
total: string;
|
||||
}[];
|
||||
aging: { bucket: string; count: number }[];
|
||||
cycle_times: {
|
||||
stage: StageValue;
|
||||
stage_label: string;
|
||||
avg_days: number;
|
||||
samples: number;
|
||||
}[];
|
||||
end_to_end_avg_days: number | null;
|
||||
top_jobs: { job_number: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface JobLookupOut {
|
||||
found: boolean;
|
||||
job_number: string;
|
||||
part_id?: string | null;
|
||||
part_description?: string | null;
|
||||
customer_name?: string | null;
|
||||
work_order_status?: string | null;
|
||||
source?: string;
|
||||
}
|
||||
117
frontend/src/auth/AuthGate.tsx
Normal file
117
frontend/src/auth/AuthGate.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { MsalProvider, useIsAuthenticated, useMsal } from "@azure/msal-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { msalInstance } from "../api/client";
|
||||
import { ApiError } from "../api/client";
|
||||
import { useMe } from "../api/hooks";
|
||||
import { config } from "../config";
|
||||
|
||||
function Centered({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center" sx={{ maxWidth: 480 }}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** After sign-in (or in dev mode), /api/me must succeed before the app loads:
|
||||
* it auto-provisions the user and enforces the front-door group. */
|
||||
function MeGate({ children }: { children: ReactNode }) {
|
||||
const me = useMe();
|
||||
|
||||
if (me.isLoading) {
|
||||
return (
|
||||
<Centered>
|
||||
<CircularProgress />
|
||||
<Typography color="text.secondary">Signing you in…</Typography>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
if (me.isError) {
|
||||
const err = me.error;
|
||||
const detail =
|
||||
err instanceof ApiError ? err.message : "Could not reach the NCR API.";
|
||||
const denied = err instanceof ApiError && err.status === 403;
|
||||
return (
|
||||
<Centered>
|
||||
<Typography variant="h5">PESCO NCR</Typography>
|
||||
<Alert severity={denied ? "warning" : "error"} sx={{ width: "100%" }}>
|
||||
{denied ? "Access denied. " : ""}
|
||||
{detail}
|
||||
</Alert>
|
||||
{denied && (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Ask IT to add you to the NCR access group, then sign in again.
|
||||
</Typography>
|
||||
)}
|
||||
<Button variant="contained" onClick={() => me.refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function EntraGate({ children }: { children: ReactNode }) {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const { instance, inProgress } = useMsal();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated && inProgress === "none") {
|
||||
void instance.loginRedirect({ scopes: [config.apiScope] });
|
||||
}
|
||||
}, [isAuthenticated, inProgress, instance]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Centered>
|
||||
<CircularProgress />
|
||||
<Typography color="text.secondary">
|
||||
Redirecting to Microsoft sign-in…
|
||||
</Typography>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
return <MeGate>{children}</MeGate>;
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
if (config.authMode === "dev") {
|
||||
return <MeGate>{children}</MeGate>;
|
||||
}
|
||||
if (!config.clientId || !config.tenantId) {
|
||||
return (
|
||||
<Centered>
|
||||
<Typography variant="h5">PESCO NCR</Typography>
|
||||
<Alert severity="error">
|
||||
Entra ID is not configured. Set ENTRA_TENANT_ID and ENTRA_CLIENT_ID in
|
||||
.env (see README), or set AUTH_MODE=dev for local development.
|
||||
</Alert>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MsalProvider instance={msalInstance!}>
|
||||
<EntraGate>{children}</EntraGate>
|
||||
</MsalProvider>
|
||||
);
|
||||
}
|
||||
189
frontend/src/components/AttachmentSection.tsx
Normal file
189
frontend/src/components/AttachmentSection.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import AttachFileIcon from "@mui/icons-material/AttachFile";
|
||||
import DescriptionIcon from "@mui/icons-material/Description";
|
||||
import PhotoCameraIcon from "@mui/icons-material/PhotoCamera";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef, useState } from "react";
|
||||
import { apiBlob, openBlob } from "../api/client";
|
||||
import { useUploadAttachments } from "../api/hooks";
|
||||
import type { AttachmentOut } from "../api/types";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
/** Images are behind the authenticated API, so <img src> can't load them
|
||||
* directly — fetch as a blob and use an object URL. */
|
||||
function useAttachmentUrl(att: AttachmentOut, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["attachment-blob", att.id],
|
||||
queryFn: async () => {
|
||||
const blob = await apiBlob(`/api/attachments/${att.id}/download`);
|
||||
return URL.createObjectURL(blob);
|
||||
},
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
gcTime: 10 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function Thumbnail({ att, onOpen }: { att: AttachmentOut; onOpen: (url: string) => void }) {
|
||||
const url = useAttachmentUrl(att, att.is_image);
|
||||
|
||||
if (!att.is_image) {
|
||||
return (
|
||||
<Tooltip title={`${att.original_filename} — click to download`}>
|
||||
<Box
|
||||
onClick={() => void openBlob(`/api/attachments/${att.id}/download`, att.original_filename, "download")}
|
||||
sx={{
|
||||
width: 96,
|
||||
height: 96,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
p: 0.5,
|
||||
}}
|
||||
>
|
||||
<DescriptionIcon color="action" />
|
||||
<Typography variant="caption" noWrap sx={{ maxWidth: 88 }}>
|
||||
{att.original_filename}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={`${att.original_filename} — ${att.uploaded_by.display_name}, ${new Date(att.uploaded_at).toLocaleString()}`}
|
||||
>
|
||||
<Box
|
||||
onClick={() => url.data && onOpen(url.data)}
|
||||
sx={{
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "#fafafa",
|
||||
}}
|
||||
>
|
||||
{url.data ? (
|
||||
<img
|
||||
src={url.data}
|
||||
alt={att.original_filename}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<CircularProgress size={20} />
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ncrId: number;
|
||||
attachments: AttachmentOut[];
|
||||
canAdd: boolean;
|
||||
}
|
||||
|
||||
export function AttachmentSection({ ncrId, attachments, canAdd }: Props) {
|
||||
const upload = useUploadAttachments(ncrId);
|
||||
const { toast } = useToast();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const cameraInput = useRef<HTMLInputElement>(null);
|
||||
const [lightbox, setLightbox] = useState<string | null>(null);
|
||||
|
||||
const handleFiles = (list: FileList | null) => {
|
||||
if (!list || list.length === 0) return;
|
||||
upload.mutate(Array.from(list), {
|
||||
onSuccess: (items) =>
|
||||
toast(`${items.length} attachment${items.length > 1 ? "s" : ""} added.`),
|
||||
onError: (err) => toast(err.message, "error"),
|
||||
});
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
if (cameraInput.current) cameraInput.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
|
||||
{attachments.map((att) => (
|
||||
<Thumbnail key={att.id} att={att} onOpen={setLightbox} />
|
||||
))}
|
||||
{attachments.length === 0 && (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
No attachments yet.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{canAdd && (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
startIcon={<PhotoCameraIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => cameraInput.current?.click()}
|
||||
disabled={upload.isPending}
|
||||
>
|
||||
Take Photo
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={upload.isPending ? <CircularProgress size={16} /> : <AttachFileIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={upload.isPending}
|
||||
>
|
||||
Add Files
|
||||
</Button>
|
||||
{/* capture="environment" opens the rear camera on tablets/phones */}
|
||||
<input
|
||||
ref={cameraInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
hidden
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.msg,.eml"
|
||||
hidden
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Dialog open={lightbox !== null} onClose={() => setLightbox(null)} maxWidth="lg">
|
||||
<DialogContent sx={{ p: 0.5 }}>
|
||||
{lightbox && (
|
||||
<img
|
||||
src={lightbox}
|
||||
alt="attachment"
|
||||
style={{ maxWidth: "90vw", maxHeight: "85vh", display: "block" }}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/FieldRow.tsx
Normal file
14
frontend/src/components/FieldRow.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Grid, Typography } from "@mui/material";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Label/value pair used across the NCR detail read-only sections. */
|
||||
export function FieldRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography component="div">{children || "—"}</Typography>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/JobNumberField.tsx
Normal file
46
frontend/src/components/JobNumberField.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Chip, Stack, TextField } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useJobLookup } from "../api/hooks";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** Job number entry. Free text today (NullJobLookupService); when the VISUAL
|
||||
* provider is enabled the enrichment chips below light up automatically —
|
||||
* no redesign needed. */
|
||||
export function JobNumberField({ value, onChange, required }: Props) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [value]);
|
||||
|
||||
const lookup = useJobLookup(debounced);
|
||||
const info = lookup.data;
|
||||
|
||||
return (
|
||||
<Stack spacing={0.5}>
|
||||
<TextField
|
||||
label="Job Number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required={required}
|
||||
inputProps={{ maxLength: 100 }}
|
||||
/>
|
||||
{info?.found && (
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{info.part_id && <Chip size="small" label={`Part: ${info.part_id}`} />}
|
||||
{info.customer_name && (
|
||||
<Chip size="small" label={`Customer: ${info.customer_name}`} />
|
||||
)}
|
||||
{info.work_order_status && (
|
||||
<Chip size="small" label={`WO Status: ${info.work_order_status}`} />
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
197
frontend/src/components/Layout.tsx
Normal file
197
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import AddCircleIcon from "@mui/icons-material/AddCircle";
|
||||
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
|
||||
import AssessmentIcon from "@mui/icons-material/Assessment";
|
||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import {
|
||||
AppBar,
|
||||
Avatar,
|
||||
Box,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Select,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { getDevUser, setDevUser } from "../api/client";
|
||||
import { useMe } from "../api/hooks";
|
||||
import { config } from "../config";
|
||||
|
||||
const DRAWER_WIDTH = 232;
|
||||
|
||||
const DEV_USERS = [
|
||||
"admin@pescoinc.biz",
|
||||
"dispo@pescoinc.biz",
|
||||
"second@pescoinc.biz",
|
||||
"ops@pescoinc.biz",
|
||||
"qc@pescoinc.biz",
|
||||
"cost@pescoinc.biz",
|
||||
"req@pescoinc.biz",
|
||||
];
|
||||
|
||||
function DevUserSwitcher() {
|
||||
return (
|
||||
<Tooltip title="AUTH_MODE=dev — switch the simulated user">
|
||||
<Select
|
||||
size="small"
|
||||
value={getDevUser()}
|
||||
onChange={(e) => {
|
||||
setDevUser(e.target.value);
|
||||
window.location.reload();
|
||||
}}
|
||||
sx={{
|
||||
mr: 1,
|
||||
bgcolor: "rgba(255,255,255,0.15)",
|
||||
color: "#fff",
|
||||
".MuiSvgIcon-root": { color: "#fff" },
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{DEV_USERS.map((u) => (
|
||||
<MenuItem key={u} value={u}>
|
||||
{u.split("@")[0]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const theme = useTheme();
|
||||
const isDesktop = useMediaQuery(theme.breakpoints.up("md"));
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const me = useMe();
|
||||
const isAdmin = me.data?.roles.includes("admin") ?? false;
|
||||
|
||||
const nav = [
|
||||
{ label: "Dashboard", icon: <DashboardIcon />, path: "/" },
|
||||
{ label: "New NCR", icon: <AddCircleIcon />, path: "/ncrs/new" },
|
||||
{ label: "Search", icon: <SearchIcon />, path: "/search" },
|
||||
{ label: "Reports", icon: <AssessmentIcon />, path: "/reports" },
|
||||
...(isAdmin
|
||||
? [{ label: "Admin", icon: <AdminPanelSettingsIcon />, path: "/admin" }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const drawer = (
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Toolbar sx={{ minHeight: { xs: 56, md: 64 } }}>
|
||||
<Typography variant="h6" color="primary">
|
||||
PESCO NCR
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
{nav.map((item) => {
|
||||
const selected =
|
||||
item.path === "/"
|
||||
? location.pathname === "/"
|
||||
: location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<ListItemButton
|
||||
key={item.path}
|
||||
selected={selected}
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
sx={{ minHeight: 48 }}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{ zIndex: theme.zIndex.drawer + 1 }}
|
||||
elevation={1}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: { xs: 56, md: 64 } }}>
|
||||
{!isDesktop && (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
edge="start"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }} noWrap>
|
||||
Non-Conformance Reports
|
||||
</Typography>
|
||||
{config.authMode === "dev" && <DevUserSwitcher />}
|
||||
{me.data && (
|
||||
<Tooltip title={`${me.data.display_name} (${me.data.email})`}>
|
||||
<Avatar sx={{ bgcolor: "secondary.main", width: 36, height: 36 }}>
|
||||
{me.data.display_name
|
||||
.split(" ")
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join("")}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{isDesktop ? (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: DRAWER_WIDTH,
|
||||
flexShrink: 0,
|
||||
"& .MuiDrawer-paper": { width: DRAWER_WIDTH, boxSizing: "border-box" },
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{ "& .MuiDrawer-paper": { width: DRAWER_WIDTH } }}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: { xs: 1.5, sm: 2, md: 3 },
|
||||
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||
mt: { xs: "56px", md: "64px" },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
144
frontend/src/components/QueueTable.tsx
Normal file
144
frontend/src/components/QueueTable.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { NcrListItem } from "../api/types";
|
||||
import { StageChip } from "./StageChip";
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: NcrListItem[];
|
||||
total: number;
|
||||
page: number; // 1-based
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** Responsive queue view: a dense table on desktop, tap-friendly cards on
|
||||
* phones/tablets in portrait. */
|
||||
export function QueueTable({ items, total, page, pageSize, onPageChange, loading }: Props) {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<Box sx={{ py: 6, textAlign: "center" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: "center" }}>
|
||||
No NCRs in this queue.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const pagination = (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page - 1}
|
||||
onPageChange={(_, p) => onPageChange(p + 1)}
|
||||
rowsPerPage={pageSize}
|
||||
rowsPerPageOptions={[pageSize]}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isSmall) {
|
||||
return (
|
||||
<Box>
|
||||
<Stack spacing={1}>
|
||||
{items.map((n) => (
|
||||
<Card key={n.id} variant="outlined">
|
||||
<CardActionArea onClick={() => navigate(`/ncrs/${n.id}`)}>
|
||||
<CardContent sx={{ py: 1.5 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography fontWeight={700}>{n.ncr_number}</Typography>
|
||||
<StageChip stage={n.stage} />
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Job {n.job_number} · {n.department} · {n.deviation_category}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{n.requester} · {fmtDate(n.created_at)} · {n.days_in_stage}d in
|
||||
stage
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
{pagination}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>NCR #</TableCell>
|
||||
<TableCell>Job #</TableCell>
|
||||
<TableCell>Department</TableCell>
|
||||
<TableCell>Requester</TableCell>
|
||||
<TableCell>Category</TableCell>
|
||||
<TableCell>Stage</TableCell>
|
||||
<TableCell align="right">Days in Stage</TableCell>
|
||||
<TableCell align="right">Created</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((n) => (
|
||||
<TableRow
|
||||
key={n.id}
|
||||
hover
|
||||
sx={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/ncrs/${n.id}`)}
|
||||
>
|
||||
<TableCell sx={{ fontWeight: 700 }}>{n.ncr_number}</TableCell>
|
||||
<TableCell>{n.job_number}</TableCell>
|
||||
<TableCell>{n.department}</TableCell>
|
||||
<TableCell>{n.requester}</TableCell>
|
||||
<TableCell>{n.deviation_category}</TableCell>
|
||||
<TableCell>
|
||||
<StageChip stage={n.stage} />
|
||||
</TableCell>
|
||||
<TableCell align="right">{n.days_in_stage}</TableCell>
|
||||
<TableCell align="right">{fmtDate(n.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{pagination}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
141
frontend/src/components/RichTextEditor.tsx
Normal file
141
frontend/src/components/RichTextEditor.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import FormatBoldIcon from "@mui/icons-material/FormatBold";
|
||||
import FormatItalicIcon from "@mui/icons-material/FormatItalic";
|
||||
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
|
||||
import FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import RedoIcon from "@mui/icons-material/Redo";
|
||||
import StrikethroughSIcon from "@mui/icons-material/StrikethroughS";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import { Box, Divider, ToggleButton, Typography } from "@mui/material";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
/** Rich-text editor for disposition notes. Output HTML is sanitized again
|
||||
* server-side (nh3) before storage. */
|
||||
export function RichTextEditor({ label, value, onChange, minHeight = 140 }: Props) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
],
|
||||
content: value,
|
||||
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML() && !editor.isFocused) {
|
||||
editor.commands.setContent(value || "", false);
|
||||
}
|
||||
}, [value, editor]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
const btn = (
|
||||
active: boolean,
|
||||
onClick: () => void,
|
||||
icon: React.ReactNode,
|
||||
title: string,
|
||||
) => (
|
||||
<ToggleButton
|
||||
value={title}
|
||||
selected={active}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
size="small"
|
||||
sx={{ border: 0, px: 1 }}
|
||||
title={title}
|
||||
>
|
||||
{icon}
|
||||
</ToggleButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{label && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
"&:focus-within": { borderColor: "primary.main" },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", p: 0.5, gap: 0.25 }}>
|
||||
{btn(
|
||||
editor.isActive("bold"),
|
||||
() => editor.chain().focus().toggleBold().run(),
|
||||
<FormatBoldIcon fontSize="small" />,
|
||||
"Bold",
|
||||
)}
|
||||
{btn(
|
||||
editor.isActive("italic"),
|
||||
() => editor.chain().focus().toggleItalic().run(),
|
||||
<FormatItalicIcon fontSize="small" />,
|
||||
"Italic",
|
||||
)}
|
||||
{btn(
|
||||
editor.isActive("strike"),
|
||||
() => editor.chain().focus().toggleStrike().run(),
|
||||
<StrikethroughSIcon fontSize="small" />,
|
||||
"Strikethrough",
|
||||
)}
|
||||
{btn(
|
||||
editor.isActive("bulletList"),
|
||||
() => editor.chain().focus().toggleBulletList().run(),
|
||||
<FormatListBulletedIcon fontSize="small" />,
|
||||
"Bullet list",
|
||||
)}
|
||||
{btn(
|
||||
editor.isActive("orderedList"),
|
||||
() => editor.chain().focus().toggleOrderedList().run(),
|
||||
<FormatListNumberedIcon fontSize="small" />,
|
||||
"Numbered list",
|
||||
)}
|
||||
{btn(
|
||||
editor.isActive("link"),
|
||||
() => {
|
||||
if (editor.isActive("link")) {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
return;
|
||||
}
|
||||
const url = window.prompt("Link URL (https://…)");
|
||||
if (url) editor.chain().focus().setLink({ href: url }).run();
|
||||
},
|
||||
<LinkIcon fontSize="small" />,
|
||||
"Link",
|
||||
)}
|
||||
<Divider flexItem orientation="vertical" sx={{ mx: 0.5 }} />
|
||||
{btn(false, () => editor.chain().focus().undo().run(), <UndoIcon fontSize="small" />, "Undo")}
|
||||
{btn(false, () => editor.chain().focus().redo().run(), <RedoIcon fontSize="small" />, "Redo")}
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
minHeight,
|
||||
"& .ProseMirror": { outline: "none", minHeight: minHeight - 20 },
|
||||
"& .ProseMirror p": { m: 0, mb: 0.5 },
|
||||
}}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/RichTextView.tsx
Normal file
16
frontend/src/components/RichTextView.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
/** Renders server-sanitized rich text (the API cleans all HTML with nh3
|
||||
* before storing it, so this content is trusted). */
|
||||
export function RichTextView({ html }: { html: string }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
"& p": { mt: 0, mb: 0.75 },
|
||||
"& ul, & ol": { mt: 0, pl: 3 },
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/StageChip.tsx
Normal file
21
frontend/src/components/StageChip.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Chip } from "@mui/material";
|
||||
import type { StageValue } from "../api/types";
|
||||
import { STAGE_LABELS } from "../api/types";
|
||||
import { STAGE_COLORS } from "../theme";
|
||||
|
||||
export function StageChip({
|
||||
stage,
|
||||
size = "small",
|
||||
}: {
|
||||
stage: StageValue;
|
||||
size?: "small" | "medium";
|
||||
}) {
|
||||
const colors = STAGE_COLORS[stage] ?? { bg: "#eee", fg: "#333" };
|
||||
return (
|
||||
<Chip
|
||||
label={STAGE_LABELS[stage] ?? stage}
|
||||
size={size}
|
||||
sx={{ bgcolor: colors.bg, color: colors.fg, fontWeight: 600 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/StageStepper.tsx
Normal file
30
frontend/src/components/StageStepper.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Step, StepLabel, Stepper, useMediaQuery, useTheme } from "@mui/material";
|
||||
import type { NcrDetail } from "../api/types";
|
||||
import { STAGE_LABELS, STAGE_ORDER } from "../api/types";
|
||||
|
||||
/** Visual progress through the workflow. Secondary Disposition is only shown
|
||||
* when that route was taken. */
|
||||
export function StageStepper({ ncr }: { ncr: NcrDetail }) {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
const stages = STAGE_ORDER.filter(
|
||||
(s) => s !== "secondary_disposition" || ncr.secondary_review_needed,
|
||||
);
|
||||
const activeIndex = stages.indexOf(ncr.stage);
|
||||
|
||||
return (
|
||||
<Stepper
|
||||
activeStep={ncr.stage === "closed" ? stages.length : activeIndex}
|
||||
alternativeLabel={!isSmall}
|
||||
orientation={isSmall ? "vertical" : "horizontal"}
|
||||
sx={{ my: 1 }}
|
||||
>
|
||||
{stages.map((s) => (
|
||||
<Step key={s} completed={stages.indexOf(s) < activeIndex || ncr.stage === "closed"}>
|
||||
<StepLabel>{STAGE_LABELS[s]}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/Toast.tsx
Normal file
74
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Alert, Snackbar, Stack } from "@mui/material";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
|
||||
type Severity = "success" | "info" | "warning" | "error";
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
severity: Severity;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (message: string, severity?: Severity) => void;
|
||||
warnings: (messages: string[]) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>({
|
||||
toast: () => {},
|
||||
warnings: () => {},
|
||||
});
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const toast = useCallback((message: string, severity: Severity = "success") => {
|
||||
setToasts((prev) => [...prev, { id: nextId++, message, severity }]);
|
||||
}, []);
|
||||
|
||||
const warnings = useCallback(
|
||||
(messages: string[]) => {
|
||||
for (const m of messages) toast(m, "warning");
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const dismiss = (id: number) =>
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast, warnings }}>
|
||||
{children}
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{ position: "fixed", bottom: 16, left: 16, zIndex: 2000, maxWidth: 420 }}
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<Snackbar
|
||||
key={t.id}
|
||||
open
|
||||
autoHideDuration={t.severity === "warning" ? 10000 : 4000}
|
||||
onClose={() => dismiss(t.id)}
|
||||
sx={{ position: "static", transform: "none" }}
|
||||
>
|
||||
<Alert
|
||||
severity={t.severity}
|
||||
onClose={() => dismiss(t.id)}
|
||||
variant="filled"
|
||||
sx={{ width: "100%" }}
|
||||
>
|
||||
{t.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
))}
|
||||
</Stack>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/UserPicker.tsx
Normal file
46
frontend/src/components/UserPicker.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Autocomplete, TextField } from "@mui/material";
|
||||
import { useUsersByRole } from "../api/hooks";
|
||||
import type { UserOut } from "../api/types";
|
||||
|
||||
interface Props {
|
||||
role: string;
|
||||
label: string;
|
||||
multiple?: boolean;
|
||||
value: UserOut[] | UserOut | null;
|
||||
onChange: (value: UserOut[] | UserOut | null) => void;
|
||||
helperText?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** Picker over users holding a given in-app role (drives the Disposition
|
||||
* Authority dropdown and "Notify These People"). */
|
||||
export function UserPicker({
|
||||
role,
|
||||
label,
|
||||
multiple = false,
|
||||
value,
|
||||
onChange,
|
||||
helperText,
|
||||
required,
|
||||
}: Props) {
|
||||
const users = useUsersByRole(role);
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple={multiple}
|
||||
options={users.data ?? []}
|
||||
loading={users.isLoading}
|
||||
value={value as never}
|
||||
onChange={(_, v) => onChange(v as never)}
|
||||
getOptionLabel={(u: UserOut) => u.display_name}
|
||||
isOptionEqualToValue={(a: UserOut, b: UserOut) => a.id === b.id}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={label}
|
||||
helperText={helperText}
|
||||
required={required}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
frontend/src/config.ts
Normal file
22
frontend/src/config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface AppConfig {
|
||||
authMode: "entra" | "dev";
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
apiScope: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_CONFIG__?: Partial<AppConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
const w = window.__APP_CONFIG__ ?? {};
|
||||
|
||||
export const config: AppConfig = {
|
||||
authMode: w.authMode === "entra" ? "entra" : "dev",
|
||||
tenantId: w.tenantId ?? "",
|
||||
clientId: w.clientId ?? "",
|
||||
apiScope:
|
||||
w.apiScope || (w.clientId ? `api://${w.clientId}/access_as_user` : ""),
|
||||
};
|
||||
48
frontend/src/main.tsx
Normal file
48
frontend/src/main.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { CssBaseline, ThemeProvider } from "@mui/material";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { msalInstance } from "./api/client";
|
||||
import { AuthGate } from "./auth/AuthGate";
|
||||
import { ToastProvider } from "./components/Toast";
|
||||
import { theme } from "./theme";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
if (msalInstance) {
|
||||
await msalInstance.initialize();
|
||||
const result = await msalInstance.handleRedirectPromise();
|
||||
if (result?.account) {
|
||||
msalInstance.setActiveAccount(result.account);
|
||||
} else {
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length > 0) msalInstance.setActiveAccount(accounts[0]);
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<AuthGate>
|
||||
<App />
|
||||
</AuthGate>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
186
frontend/src/pages/DashboardPage.tsx
Normal file
186
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { buildQuery, openBlob } from "../api/client";
|
||||
import { useMe, useQueue, useUsersByRole } from "../api/hooks";
|
||||
import type { QueueFilters } from "../api/types";
|
||||
import { QueueTable } from "../components/QueueTable";
|
||||
import { useToast } from "../components/Toast";
|
||||
|
||||
interface QueueDef {
|
||||
key: string;
|
||||
label: string;
|
||||
visible: (roles: string[]) => boolean;
|
||||
}
|
||||
|
||||
const QUEUES: QueueDef[] = [
|
||||
{ key: "my_requests", label: "My Requests", visible: () => true },
|
||||
{
|
||||
key: "new_requests",
|
||||
label: "New Requests",
|
||||
visible: (r) => r.includes("disposition_authority") || r.includes("admin"),
|
||||
},
|
||||
{
|
||||
key: "secondary",
|
||||
label: "My Secondary Queue",
|
||||
visible: (r) =>
|
||||
r.includes("secondary_disposition_authority") || r.includes("admin"),
|
||||
},
|
||||
{
|
||||
key: "operations",
|
||||
label: "Operations",
|
||||
visible: (r) => r.includes("operations") || r.includes("admin"),
|
||||
},
|
||||
{
|
||||
key: "inspection",
|
||||
label: "QC Inspection",
|
||||
visible: (r) => r.includes("qc_inspector") || r.includes("admin"),
|
||||
},
|
||||
{
|
||||
key: "costing",
|
||||
label: "Awaiting Costing",
|
||||
visible: (r) => r.includes("costing") || r.includes("admin"),
|
||||
},
|
||||
{ key: "recently_closed", label: "Recently Closed", visible: () => true },
|
||||
];
|
||||
|
||||
export function DashboardPage() {
|
||||
const me = useMe();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const roles = useMemo(() => me.data?.roles ?? [], [me.data]);
|
||||
const queues = useMemo(() => QUEUES.filter((q) => q.visible(roles)), [roles]);
|
||||
|
||||
const [tab, setTab] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [jobFilter, setJobFilter] = useState("");
|
||||
const [authorityFilter, setAuthorityFilter] = useState<number | "">("");
|
||||
|
||||
const active = queues[Math.min(tab, queues.length - 1)];
|
||||
const isNewRequests = active?.key === "new_requests";
|
||||
const authorities = useUsersByRole("disposition_authority");
|
||||
|
||||
const filters: QueueFilters = isNewRequests
|
||||
? {
|
||||
job_number: jobFilter || undefined,
|
||||
disposition_authority_id: authorityFilter || undefined,
|
||||
}
|
||||
: {};
|
||||
|
||||
const queue = useQueue(active?.key ?? "my_requests", filters, page);
|
||||
|
||||
const exportCsv = () => {
|
||||
void openBlob(
|
||||
`/api/ncrs/export.csv${buildQuery({ queue: active.key, ...filters })}`,
|
||||
`ncr-${active.key}.csv`,
|
||||
"download",
|
||||
).catch((e) => toast(e.message, "error"));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ sm: "center" }}
|
||||
spacing={1}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Typography variant="h5">Dashboard</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => navigate("/ncrs/new")}
|
||||
>
|
||||
New NCR
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<Tabs
|
||||
value={Math.min(tab, queues.length - 1)}
|
||||
onChange={(_, v) => {
|
||||
setTab(v);
|
||||
setPage(1);
|
||||
}}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||
>
|
||||
{queues.map((q) => (
|
||||
<Tab key={q.key} label={q.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
<CardContent>
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={1}
|
||||
sx={{ mb: 1 }}
|
||||
alignItems={{ sm: "center" }}
|
||||
>
|
||||
{isNewRequests && (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Filter by job number"
|
||||
value={jobFilter}
|
||||
onChange={(e) => {
|
||||
setJobFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
select
|
||||
label="Disposition authority"
|
||||
value={authorityFilter}
|
||||
onChange={(e) => {
|
||||
setAuthorityFilter(
|
||||
e.target.value === "" ? "" : Number(e.target.value),
|
||||
);
|
||||
setPage(1);
|
||||
}}
|
||||
sx={{ minWidth: 220 }}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{(authorities.data ?? []).map((u) => (
|
||||
<MenuItem key={u.id} value={u.id}>
|
||||
{u.display_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</>
|
||||
)}
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button startIcon={<DownloadIcon />} onClick={exportCsv}>
|
||||
Export CSV
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<QueueTable
|
||||
items={queue.data?.items ?? []}
|
||||
total={queue.data?.total ?? 0}
|
||||
page={page}
|
||||
pageSize={25}
|
||||
onPageChange={setPage}
|
||||
loading={queue.isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
421
frontend/src/pages/NcrDetailPage.tsx
Normal file
421
frontend/src/pages/NcrDetailPage.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Grid,
|
||||
Stack,
|
||||
Tab,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { openBlob } from "../api/client";
|
||||
import { useMe, useNcr, useNcrAudit } from "../api/hooks";
|
||||
import type { NcrDetail } from "../api/types";
|
||||
import { STAGE_LABELS } from "../api/types";
|
||||
import { AttachmentSection } from "../components/AttachmentSection";
|
||||
import { FieldRow } from "../components/FieldRow";
|
||||
import { RichTextView } from "../components/RichTextView";
|
||||
import { StageChip } from "../components/StageChip";
|
||||
import { StageStepper } from "../components/StageStepper";
|
||||
import { useToast } from "../components/Toast";
|
||||
import {
|
||||
CostingForm,
|
||||
InitialDispositionForm,
|
||||
InspectionForm,
|
||||
OperationsForm,
|
||||
ReopenDialog,
|
||||
SecondaryDispositionForm,
|
||||
} from "./StageForms";
|
||||
|
||||
function fmt(iso: string | null): string {
|
||||
return iso ? new Date(iso).toLocaleString() : "—";
|
||||
}
|
||||
|
||||
function money(v: string | null): string {
|
||||
return v === null
|
||||
? "—"
|
||||
: Number(v).toLocaleString(undefined, { style: "currency", currency: "USD" });
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardHeader title={title} action={action} titleTypographyProps={{ variant: "h6" }} />
|
||||
<Divider />
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditTab({ ncrId }: { ncrId: number }) {
|
||||
const audit = useNcrAudit(ncrId, true);
|
||||
if (audit.isLoading) return <CircularProgress sx={{ m: 2 }} />;
|
||||
if (audit.isError)
|
||||
return <Alert severity="error">{(audit.error as Error).message}</Alert>;
|
||||
const items = audit.data?.items ?? [];
|
||||
return (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>When</TableCell>
|
||||
<TableCell>Who</TableCell>
|
||||
<TableCell>Action</TableCell>
|
||||
<TableCell>Field</TableCell>
|
||||
<TableCell>Before</TableCell>
|
||||
<TableCell>After</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }}>{fmt(a.created_at)}</TableCell>
|
||||
<TableCell>{a.user.display_name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={a.action.replace(/_/g, " ")} />
|
||||
{a.detail && (
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
{a.detail}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{a.field_name ?? ""}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 220, overflowWrap: "anywhere" }}>
|
||||
{a.old_value ?? ""}
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 220, overflowWrap: "anywhere" }}>
|
||||
{a.new_value ?? ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailBody({ ncr }: { ncr: NcrDetail }) {
|
||||
const actions = ncr.available_actions;
|
||||
const closed = ncr.stage === "closed";
|
||||
|
||||
return (
|
||||
<>
|
||||
{closed && (
|
||||
<Alert icon={<LockIcon />} severity="info" sx={{ mb: 2 }}>
|
||||
This NCR is closed and locked. Closed on {fmt(ncr.closed_at)} by{" "}
|
||||
{ncr.closed_by?.display_name}. Only an Admin can reopen it.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SectionCard title="Request">
|
||||
<Grid container spacing={2}>
|
||||
<FieldRow label="Job Number">{ncr.job_number}</FieldRow>
|
||||
<FieldRow label="Date">{new Date(ncr.created_at).toLocaleDateString()}</FieldRow>
|
||||
<FieldRow label="Department">{ncr.department}</FieldRow>
|
||||
<FieldRow label="Deviation Category">{ncr.deviation_category}</FieldRow>
|
||||
<FieldRow label="Requester">{ncr.requester.display_name}</FieldRow>
|
||||
<FieldRow label="Disposition Authority">
|
||||
{ncr.disposition_authority.display_name}
|
||||
</FieldRow>
|
||||
{ncr.job_info && (
|
||||
<>
|
||||
<FieldRow label="Part (ERP)">
|
||||
{[ncr.job_info.part_id, ncr.job_info.part_description]
|
||||
.filter(Boolean)
|
||||
.join(" — ")}
|
||||
</FieldRow>
|
||||
<FieldRow label="Customer (ERP)">{ncr.job_info.customer_name}</FieldRow>
|
||||
<FieldRow label="WO Status (ERP)">{ncr.job_info.work_order_status}</FieldRow>
|
||||
</>
|
||||
)}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Deviation Detail
|
||||
</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{ncr.deviation_detail}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Attachments
|
||||
</Typography>
|
||||
<AttachmentSection
|
||||
ncrId={ncr.id}
|
||||
attachments={ncr.attachments}
|
||||
canAdd={actions.includes("add_attachment")}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Disposition">
|
||||
{ncr.stage === "new_request" && !actions.includes("initial_disposition") ? (
|
||||
<Typography color="text.secondary">
|
||||
Awaiting initial disposition by {ncr.disposition_authority.display_name}.
|
||||
</Typography>
|
||||
) : (
|
||||
<Grid container spacing={2} sx={{ mb: 1 }}>
|
||||
<FieldRow label="QC Authority">{ncr.qc_authority}</FieldRow>
|
||||
<FieldRow label="Work Order">{ncr.work_order}</FieldRow>
|
||||
<FieldRow label="Secondary Review">
|
||||
{ncr.secondary_review_needed === null
|
||||
? "—"
|
||||
: ncr.secondary_review_needed
|
||||
? `Yes — ${ncr.secondary_authorities.map((u) => u.display_name).join(", ") || "unassigned"}`
|
||||
: "No"}
|
||||
</FieldRow>
|
||||
{ncr.disposition_notes && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Disposition Notes
|
||||
</Typography>
|
||||
<RichTextView html={ncr.disposition_notes} />
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
{actions.includes("initial_disposition") && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }}>
|
||||
<Chip label="Initial Disposition — your action" color="primary" size="small" />
|
||||
</Divider>
|
||||
<InitialDispositionForm ncr={ncr} />
|
||||
</>
|
||||
)}
|
||||
{actions.includes("secondary_disposition") && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }}>
|
||||
<Chip label="Secondary Disposition — your action" color="primary" size="small" />
|
||||
</Divider>
|
||||
<SecondaryDispositionForm ncr={ncr} />
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Operations">
|
||||
<Grid container spacing={2}>
|
||||
<FieldRow label="Operations Complete">
|
||||
{ncr.operations_complete ? "Yes" : "Pending"}
|
||||
</FieldRow>
|
||||
<FieldRow label="Completed By">
|
||||
{ncr.operations_completed_by?.display_name}
|
||||
</FieldRow>
|
||||
<FieldRow label="Completed At">{fmt(ncr.operations_completed_at)}</FieldRow>
|
||||
</Grid>
|
||||
{actions.includes("operations_complete") && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }}>
|
||||
<Chip label="Operations — your action" color="primary" size="small" />
|
||||
</Divider>
|
||||
<OperationsForm ncr={ncr} />
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="QC Inspection">
|
||||
<Grid container spacing={2}>
|
||||
<FieldRow label="QC Approval">
|
||||
{ncr.qc_approval === null ? "—" : ncr.qc_approval === "yes" ? "Yes" : "No"}
|
||||
</FieldRow>
|
||||
<FieldRow label="QC Closed">
|
||||
{ncr.qc_closed
|
||||
? `Yes — ${ncr.qc_closed_by?.display_name}, ${fmt(ncr.qc_closed_at)}`
|
||||
: "Pending"}
|
||||
</FieldRow>
|
||||
{ncr.inspection_notes && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Inspection Notes
|
||||
</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{ncr.inspection_notes}</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
{actions.includes("inspection") && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }}>
|
||||
<Chip label="QC Inspection — your action" color="primary" size="small" />
|
||||
</Divider>
|
||||
<InspectionForm ncr={ncr} />
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Costing">
|
||||
<Grid container spacing={2}>
|
||||
<FieldRow label="Labor">{money(ncr.labor_cost)}</FieldRow>
|
||||
<FieldRow label="Material">{money(ncr.material_cost)}</FieldRow>
|
||||
<FieldRow label="Service">{money(ncr.service_cost)}</FieldRow>
|
||||
<FieldRow label="Other">{money(ncr.other_cost)}</FieldRow>
|
||||
<FieldRow label="Total">
|
||||
<Typography component="span" fontWeight={700}>
|
||||
{money(ncr.total_cost)}
|
||||
</Typography>
|
||||
</FieldRow>
|
||||
<FieldRow label="Costed By">
|
||||
{ncr.costing_completed_by
|
||||
? `${ncr.costing_completed_by.display_name}, ${fmt(ncr.costing_completed_at)}`
|
||||
: null}
|
||||
</FieldRow>
|
||||
</Grid>
|
||||
{actions.includes("costing") && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }}>
|
||||
<Chip label="Costing — your action" color="primary" size="small" />
|
||||
</Divider>
|
||||
<CostingForm ncr={ncr} />
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Workflow History">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>When</TableCell>
|
||||
<TableCell>Action</TableCell>
|
||||
<TableCell>From</TableCell>
|
||||
<TableCell>To</TableCell>
|
||||
<TableCell>By</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ncr.transitions.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }}>{fmt(t.acted_at)}</TableCell>
|
||||
<TableCell>
|
||||
{t.action.replace(/_/g, " ")}
|
||||
{t.note && (
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
{t.note}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{t.from_stage ? STAGE_LABELS[t.from_stage] : "—"}</TableCell>
|
||||
<TableCell>{STAGE_LABELS[t.to_stage]}</TableCell>
|
||||
<TableCell>{t.acted_by.display_name}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SectionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function NcrDetailPage() {
|
||||
const { id } = useParams();
|
||||
const ncrId = Number(id);
|
||||
const ncrQuery = useNcr(Number.isFinite(ncrId) ? ncrId : undefined);
|
||||
const me = useMe();
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState(0);
|
||||
const [reopenOpen, setReopenOpen] = useState(false);
|
||||
|
||||
if (ncrQuery.isLoading) {
|
||||
return (
|
||||
<Box sx={{ textAlign: "center", py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (ncrQuery.isError || !ncrQuery.data) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
{(ncrQuery.error as Error | undefined)?.message ?? "NCR not found."}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
const ncr = ncrQuery.data;
|
||||
const canAudit =
|
||||
ncr.available_actions.includes("view_audit") ||
|
||||
(me.data?.roles.includes("admin") ?? false);
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1100, mx: "auto" }}>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ md: "center" }}
|
||||
spacing={1}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Typography variant="h5">{ncr.ncr_number}</Typography>
|
||||
<StageChip stage={ncr.stage} size="medium" />
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{ncr.days_in_stage}d in stage
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
startIcon={<PictureAsPdfIcon />}
|
||||
variant="outlined"
|
||||
onClick={() =>
|
||||
void openBlob(`/api/ncrs/${ncr.id}/pdf`, `${ncr.ncr_number}.pdf`, "open").catch(
|
||||
(e) => toast(e.message, "error"),
|
||||
)
|
||||
}
|
||||
>
|
||||
Print / Download PDF
|
||||
</Button>
|
||||
{ncr.available_actions.includes("reopen") && (
|
||||
<Button
|
||||
startIcon={<LockOpenIcon />}
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={() => setReopenOpen(true)}
|
||||
>
|
||||
Reopen
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<StageStepper ncr={ncr} />
|
||||
|
||||
{canAudit ? (
|
||||
<>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label="Details" />
|
||||
<Tab icon={<HistoryIcon />} iconPosition="start" label="Audit History" />
|
||||
</Tabs>
|
||||
{tab === 0 ? (
|
||||
<DetailBody ncr={ncr} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<AuditTab ncrId={ncr.id} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<DetailBody ncr={ncr} />
|
||||
)}
|
||||
|
||||
<ReopenDialog ncr={ncr} open={reopenOpen} onClose={() => setReopenOpen(false)} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
193
frontend/src/pages/NewNcrPage.tsx
Normal file
193
frontend/src/pages/NewNcrPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { useLookups } from "../api/hooks";
|
||||
import type { NcrDetail, NcrMutationOut, UserOut } from "../api/types";
|
||||
import { AttachmentSection } from "../components/AttachmentSection";
|
||||
import { JobNumberField } from "../components/JobNumberField";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { UserPicker } from "../components/UserPicker";
|
||||
|
||||
export function NewNcrPage() {
|
||||
const lookups = useLookups();
|
||||
const navigate = useNavigate();
|
||||
const { warnings } = useToast();
|
||||
|
||||
const [jobNumber, setJobNumber] = useState("");
|
||||
const [departmentId, setDepartmentId] = useState<number | "">("");
|
||||
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||
const [authority, setAuthority] = useState<UserOut | null>(null);
|
||||
const [detail, setDetail] = useState("");
|
||||
const [created, setCreated] = useState<NcrDetail | null>(null);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
api<NcrMutationOut>("/api/ncrs", {
|
||||
method: "POST",
|
||||
body: {
|
||||
job_number: jobNumber.trim(),
|
||||
department_id: departmentId,
|
||||
deviation_category_id: categoryId,
|
||||
disposition_authority_id: authority?.id,
|
||||
deviation_detail: detail.trim(),
|
||||
},
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setCreated(data.ncr);
|
||||
if (data.warnings.length) warnings(data.warnings);
|
||||
window.scrollTo({ top: 0 });
|
||||
},
|
||||
});
|
||||
|
||||
const valid =
|
||||
jobNumber.trim().length > 0 &&
|
||||
departmentId !== "" &&
|
||||
categoryId !== "" &&
|
||||
authority !== null &&
|
||||
detail.trim().length >= 5;
|
||||
|
||||
// ── Confirmation screen: prominent NCR number + immediate photo upload ────
|
||||
if (created) {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720, mx: "auto" }}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: "center", py: 4 }}>
|
||||
<CheckCircleIcon color="success" sx={{ fontSize: 56 }} />
|
||||
<Typography variant="h6" sx={{ mt: 1 }}>
|
||||
NCR submitted
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h3"
|
||||
color="primary"
|
||||
sx={{ fontWeight: 800, my: 1, letterSpacing: 1 }}
|
||||
>
|
||||
{created.ncr_number}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Job {created.job_number} · {created.department} ·{" "}
|
||||
{created.deviation_category}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mt: 0.5 }}>
|
||||
{created.disposition_authority.display_name} has been notified for
|
||||
initial disposition.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ textAlign: "left", mt: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Add photos / files now (optional)
|
||||
</Typography>
|
||||
<AttachmentSection
|
||||
ncrId={created.id}
|
||||
attachments={created.attachments}
|
||||
canAdd
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 3 }}>
|
||||
<Button variant="contained" onClick={() => navigate(`/ncrs/${created.id}`)}>
|
||||
View NCR
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCreated(null);
|
||||
setJobNumber("");
|
||||
setDetail("");
|
||||
}}
|
||||
>
|
||||
Submit another
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720, mx: "auto" }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
New NCR Request
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
{create.isError && (
|
||||
<Alert severity="error">{(create.error as Error).message}</Alert>
|
||||
)}
|
||||
<JobNumberField value={jobNumber} onChange={setJobNumber} required />
|
||||
<TextField
|
||||
select
|
||||
required
|
||||
label="Department"
|
||||
value={departmentId}
|
||||
onChange={(e) => setDepartmentId(Number(e.target.value))}
|
||||
>
|
||||
{(lookups.data?.departments ?? []).map((d) => (
|
||||
<MenuItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
required
|
||||
label="Deviation Category"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||
>
|
||||
{(lookups.data?.deviation_categories ?? []).map((c) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<UserPicker
|
||||
role="disposition_authority"
|
||||
label="Disposition Authority"
|
||||
value={authority}
|
||||
onChange={(v) => setAuthority(v as UserOut | null)}
|
||||
required
|
||||
helperText="Who should review this nonconformance?"
|
||||
/>
|
||||
<TextField
|
||||
label="Deviation Detail"
|
||||
value={detail}
|
||||
onChange={(e) => setDetail(e.target.value)}
|
||||
required
|
||||
multiline
|
||||
minRows={4}
|
||||
helperText="Describe what was found, where, and how many pieces are affected."
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Photos and file attachments can be added on the next screen, right
|
||||
after the NCR number is assigned.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!valid || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
startIcon={create.isPending ? <CircularProgress size={18} /> : undefined}
|
||||
>
|
||||
Submit NCR
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
414
frontend/src/pages/ReportsPage.tsx
Normal file
414
frontend/src/pages/ReportsPage.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import TableChartIcon from "@mui/icons-material/TableChart";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Grid,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useLookups, useReportsSummary } from "../api/hooks";
|
||||
|
||||
/* Validated categorical palette (dataviz reference instance, light mode).
|
||||
* Fixed slot order — color follows the entity: labor=1 material=2 service=3
|
||||
* other=4. Aqua/yellow sit below 3:1 on white, so the cost chart ships a
|
||||
* table view (relief rule). */
|
||||
const SERIES = {
|
||||
labor: "#2a78d6",
|
||||
material: "#1baf7a",
|
||||
service: "#eda100",
|
||||
other: "#008300",
|
||||
};
|
||||
const SINGLE_HUE = "#2a78d6";
|
||||
const GRID = "#eceff1";
|
||||
const TICK = { fill: "#52514e", fontSize: 12 };
|
||||
|
||||
function money(n: number): string {
|
||||
return n.toLocaleString(undefined, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function csvDownload(filename: string, rows: Record<string, unknown>[]): void {
|
||||
if (rows.length === 0) return;
|
||||
const headers = Object.keys(rows[0]);
|
||||
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
|
||||
const csv = [
|
||||
headers.join(","),
|
||||
...rows.map((r) => headers.map((h) => esc(r[h])).join(",")),
|
||||
].join("\n");
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
function StatTile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card sx={{ flexGrow: 1, minWidth: 150 }}>
|
||||
<CardContent sx={{ py: 1.5, "&:last-child": { pb: 1.5 } }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="h5">{value}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({
|
||||
title,
|
||||
subheader,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subheader?: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card sx={{ height: "100%" }}>
|
||||
<CardHeader
|
||||
title={title}
|
||||
subheader={subheader}
|
||||
action={action}
|
||||
titleTypographyProps={{ variant: "subtitle1", fontWeight: 700 }}
|
||||
subheaderTypographyProps={{ variant: "caption" }}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsPage() {
|
||||
const lookups = useLookups();
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [departmentId, setDepartmentId] = useState<number | "">("");
|
||||
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||
const [costAsTable, setCostAsTable] = useState(false);
|
||||
|
||||
const summary = useReportsSummary({
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
department_id: departmentId || undefined,
|
||||
category_id: categoryId || undefined,
|
||||
});
|
||||
|
||||
const data = summary.data;
|
||||
const costRows = (data?.cost_over_time ?? []).map((c) => ({
|
||||
month: c.month,
|
||||
Labor: Number(c.labor),
|
||||
Material: Number(c.material),
|
||||
Service: Number(c.service),
|
||||
Other: Number(c.other),
|
||||
Total: Number(c.total),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Reports
|
||||
</Typography>
|
||||
|
||||
{/* Filters — one row above the charts */}
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent sx={{ py: 1.5, "&:last-child": { pb: 1.5 } }}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label="From"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label="To"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
select
|
||||
label="Department"
|
||||
value={departmentId}
|
||||
onChange={(e) =>
|
||||
setDepartmentId(e.target.value === "" ? "" : Number(e.target.value))
|
||||
}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="">All departments</MenuItem>
|
||||
{(lookups.data?.departments ?? []).map((d) => (
|
||||
<MenuItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small"
|
||||
select
|
||||
label="Category"
|
||||
value={categoryId}
|
||||
onChange={(e) =>
|
||||
setCategoryId(e.target.value === "" ? "" : Number(e.target.value))
|
||||
}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="">All categories</MenuItem>
|
||||
{(lookups.data?.deviation_categories ?? []).map((c) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{summary.isLoading || !data ? (
|
||||
<Box sx={{ textAlign: "center", py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Stack direction="row" spacing={1.5} sx={{ mb: 2 }} flexWrap="wrap" useFlexGap>
|
||||
<StatTile label="Total NCRs" value={String(data.total_ncrs)} />
|
||||
<StatTile label="Open" value={String(data.open_ncrs)} />
|
||||
<StatTile label="Closed" value={String(data.closed_ncrs)} />
|
||||
<StatTile label="Cost of Nonconformance" value={money(Number(data.total_cost))} />
|
||||
<StatTile
|
||||
label="Avg End-to-End"
|
||||
value={
|
||||
data.end_to_end_avg_days !== null
|
||||
? `${data.end_to_end_avg_days} days`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<ChartCard title="NCRs by Month">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={data.by_month}>
|
||||
<CartesianGrid stroke={GRID} vertical={false} />
|
||||
<XAxis dataKey="month" tick={TICK} tickLine={false} />
|
||||
<YAxis allowDecimals={false} tick={TICK} tickLine={false} axisLine={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" name="NCRs" fill={SINGLE_HUE} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<ChartCard
|
||||
title="Cost of Nonconformance Over Time"
|
||||
subheader="Closed NCRs, by month closed"
|
||||
action={
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<ToggleButton
|
||||
value="table"
|
||||
size="small"
|
||||
selected={costAsTable}
|
||||
onChange={() => setCostAsTable(!costAsTable)}
|
||||
title="Toggle table view"
|
||||
>
|
||||
<TableChartIcon fontSize="small" />
|
||||
</ToggleButton>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => csvDownload("cost-of-nonconformance.csv", costRows)}
|
||||
>
|
||||
CSV
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{costAsTable ? (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Month</TableCell>
|
||||
<TableCell align="right">Labor</TableCell>
|
||||
<TableCell align="right">Material</TableCell>
|
||||
<TableCell align="right">Service</TableCell>
|
||||
<TableCell align="right">Other</TableCell>
|
||||
<TableCell align="right">Total</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{costRows.map((r) => (
|
||||
<TableRow key={r.month}>
|
||||
<TableCell>{r.month}</TableCell>
|
||||
<TableCell align="right">{money(r.Labor)}</TableCell>
|
||||
<TableCell align="right">{money(r.Material)}</TableCell>
|
||||
<TableCell align="right">{money(r.Service)}</TableCell>
|
||||
<TableCell align="right">{money(r.Other)}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 700 }}>
|
||||
{money(r.Total)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={costRows}>
|
||||
<CartesianGrid stroke={GRID} vertical={false} />
|
||||
<XAxis dataKey="month" tick={TICK} tickLine={false} />
|
||||
<YAxis
|
||||
tick={TICK}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v: number) => money(v)}
|
||||
width={72}
|
||||
/>
|
||||
<Tooltip formatter={(v: number) => money(v)} />
|
||||
<Legend />
|
||||
{/* 2px surface gap between stacked segments via stroke */}
|
||||
<Bar dataKey="Labor" stackId="cost" fill={SERIES.labor} stroke="#fff" strokeWidth={1} />
|
||||
<Bar dataKey="Material" stackId="cost" fill={SERIES.material} stroke="#fff" strokeWidth={1} />
|
||||
<Bar dataKey="Service" stackId="cost" fill={SERIES.service} stroke="#fff" strokeWidth={1} />
|
||||
<Bar dataKey="Other" stackId="cost" fill={SERIES.other} stroke="#fff" strokeWidth={1} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<ChartCard title="NCRs by Department">
|
||||
<ResponsiveContainer width="100%" height={Math.max(200, data.by_department.length * 34)}>
|
||||
<BarChart data={data.by_department} layout="vertical">
|
||||
<CartesianGrid stroke={GRID} horizontal={false} />
|
||||
<XAxis type="number" allowDecimals={false} tick={TICK} tickLine={false} />
|
||||
<YAxis type="category" dataKey="name" width={130} tick={TICK} tickLine={false} axisLine={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" name="NCRs" fill={SINGLE_HUE} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<ChartCard title="NCRs by Deviation Category">
|
||||
<ResponsiveContainer width="100%" height={Math.max(200, data.by_category.length * 34)}>
|
||||
<BarChart data={data.by_category} layout="vertical">
|
||||
<CartesianGrid stroke={GRID} horizontal={false} />
|
||||
<XAxis type="number" allowDecimals={false} tick={TICK} tickLine={false} />
|
||||
<YAxis type="category" dataKey="name" width={160} tick={TICK} tickLine={false} axisLine={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" name="NCRs" fill={SINGLE_HUE} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<ChartCard title="Open NCR Aging" subheader="Days in current stage">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={data.aging}>
|
||||
<CartesianGrid stroke={GRID} vertical={false} />
|
||||
<XAxis dataKey="bucket" tick={TICK} tickLine={false} />
|
||||
<YAxis allowDecimals={false} tick={TICK} tickLine={false} axisLine={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" name="Open NCRs" fill={SINGLE_HUE} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<ChartCard title="Average Cycle Time per Stage" subheader="Days spent in each stage">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={data.cycle_times} layout="vertical">
|
||||
<CartesianGrid stroke={GRID} horizontal={false} />
|
||||
<XAxis type="number" tick={TICK} tickLine={false} />
|
||||
<YAxis type="category" dataKey="stage_label" width={150} tick={TICK} tickLine={false} axisLine={false} />
|
||||
<Tooltip formatter={(v: number) => `${v} days`} />
|
||||
<Bar dataKey="avg_days" name="Avg days" fill={SINGLE_HUE} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<ChartCard
|
||||
title="Top Job Numbers by NCR Count"
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => csvDownload("top-jobs.csv", data.top_jobs)}
|
||||
>
|
||||
CSV
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Job Number</TableCell>
|
||||
<TableCell align="right">NCRs</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.top_jobs.map((j) => (
|
||||
<TableRow key={j.job_number}>
|
||||
<TableCell>{j.job_number}</TableCell>
|
||||
<TableCell align="right">{j.count}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data.top_jobs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<Typography color="text.secondary">No data.</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
172
frontend/src/pages/SearchPage.tsx
Normal file
172
frontend/src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { buildQuery, openBlob } from "../api/client";
|
||||
import { useLookups, useQueue } from "../api/hooks";
|
||||
import type { QueueFilters } from "../api/types";
|
||||
import { STAGE_LABELS, STAGE_ORDER } from "../api/types";
|
||||
import { QueueTable } from "../components/QueueTable";
|
||||
import { useToast } from "../components/Toast";
|
||||
|
||||
export function SearchPage() {
|
||||
const lookups = useLookups();
|
||||
const { toast } = useToast();
|
||||
const [page, setPage] = useState(1);
|
||||
const [q, setQ] = useState("");
|
||||
const [departmentId, setDepartmentId] = useState<number | "">("");
|
||||
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||
const [stage, setStage] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
|
||||
const filters: QueueFilters = {
|
||||
q: q || undefined,
|
||||
department_id: departmentId || undefined,
|
||||
category_id: categoryId || undefined,
|
||||
stage: stage || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
};
|
||||
const results = useQueue("all", filters, page);
|
||||
|
||||
const set = <T,>(setter: (v: T) => void) => (v: T) => {
|
||||
setter(v);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Search NCRs
|
||||
</Typography>
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid item xs={12} sm={4} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="NCR # or Job #"
|
||||
value={q}
|
||||
onChange={(e) => set(setQ)(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
select
|
||||
label="Department"
|
||||
value={departmentId}
|
||||
onChange={(e) =>
|
||||
set(setDepartmentId)(e.target.value === "" ? "" : Number(e.target.value))
|
||||
}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{(lookups.data?.departments ?? []).map((d) => (
|
||||
<MenuItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
select
|
||||
label="Category"
|
||||
value={categoryId}
|
||||
onChange={(e) =>
|
||||
set(setCategoryId)(e.target.value === "" ? "" : Number(e.target.value))
|
||||
}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{(lookups.data?.deviation_categories ?? []).map((c) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
select
|
||||
label="Stage"
|
||||
value={stage}
|
||||
onChange={(e) => set(setStage)(e.target.value)}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{STAGE_ORDER.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{STAGE_LABELS[s]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={1.5}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
type="date"
|
||||
label="From"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={dateFrom}
|
||||
onChange={(e) => set(setDateFrom)(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={1.5}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
type="date"
|
||||
label="To"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={dateTo}
|
||||
onChange={(e) => set(setDateTo)(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() =>
|
||||
void openBlob(
|
||||
`/api/ncrs/export.csv${buildQuery({ queue: "all", ...filters })}`,
|
||||
"ncr-search.csv",
|
||||
"download",
|
||||
).catch((e) => toast(e.message, "error"))
|
||||
}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
</Box>
|
||||
<QueueTable
|
||||
items={results.data?.items ?? []}
|
||||
total={results.data?.total ?? 0}
|
||||
page={page}
|
||||
pageSize={25}
|
||||
onPageChange={setPage}
|
||||
loading={results.isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
455
frontend/src/pages/StageForms.tsx
Normal file
455
frontend/src/pages/StageForms.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
/** Editable forms for the current workflow stage. Visibility is driven by the
|
||||
* server's `available_actions`; the API re-enforces role + stage on submit. */
|
||||
import SendIcon from "@mui/icons-material/Send";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { useNcrMutation } from "../api/hooks";
|
||||
import type { NcrDetail, NcrMutationOut, UserOut } from "../api/types";
|
||||
import { STAGE_LABELS } from "../api/types";
|
||||
import { RichTextEditor } from "../components/RichTextEditor";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { UserPicker } from "../components/UserPicker";
|
||||
|
||||
function useWarnToast() {
|
||||
const { warnings, toast } = useToast();
|
||||
return { warnings, toast };
|
||||
}
|
||||
|
||||
// ── Initial Disposition ──────────────────────────────────────────────────────
|
||||
export function InitialDispositionForm({ ncr }: { ncr: NcrDetail }) {
|
||||
const { warnings, toast } = useWarnToast();
|
||||
const [qcAuthority, setQcAuthority] = useState(ncr.qc_authority ?? "");
|
||||
const [workOrder, setWorkOrder] = useState(ncr.work_order ?? "");
|
||||
const [notes, setNotes] = useState(ncr.disposition_notes ?? "");
|
||||
const [secondary, setSecondary] = useState(false);
|
||||
const [assignees, setAssignees] = useState<UserOut[]>([]);
|
||||
|
||||
const mutation = useNcrMutation(
|
||||
() =>
|
||||
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/initial-disposition`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
qc_authority: qcAuthority || null,
|
||||
work_order: workOrder || null,
|
||||
disposition_notes: notes || null,
|
||||
secondary_review_needed: secondary,
|
||||
secondary_authority_ids: assignees.map((u) => u.id),
|
||||
},
|
||||
}),
|
||||
warnings,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{mutation.isError && (
|
||||
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||
)}
|
||||
<TextField
|
||||
label="QC Authority"
|
||||
value={qcAuthority}
|
||||
onChange={(e) => setQcAuthority(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Work Order"
|
||||
value={workOrder}
|
||||
onChange={(e) => setWorkOrder(e.target.value)}
|
||||
/>
|
||||
<RichTextEditor label="Disposition Notes" value={notes} onChange={setNotes} />
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox checked={secondary} onChange={(e) => setSecondary(e.target.checked)} />
|
||||
}
|
||||
label="Secondary review needed"
|
||||
/>
|
||||
{secondary && (
|
||||
<UserPicker
|
||||
role="secondary_disposition_authority"
|
||||
label="Notify These People"
|
||||
multiple
|
||||
value={assignees}
|
||||
onChange={(v) => setAssignees((v as UserOut[]) ?? [])}
|
||||
helperText="The selected secondary disposition authorities will see this NCR in their personal queue."
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||
disabled={mutation.isPending || (secondary && assignees.length === 0)}
|
||||
onClick={() =>
|
||||
mutation.mutate(undefined as never, {
|
||||
onSuccess: () =>
|
||||
toast(
|
||||
secondary
|
||||
? "Sent for secondary disposition review."
|
||||
: "Released to Operations.",
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
{secondary ? "Send for Secondary Review" : "Release to Operations"}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Secondary Disposition ────────────────────────────────────────────────────
|
||||
export function SecondaryDispositionForm({ ncr }: { ncr: NcrDetail }) {
|
||||
const { warnings, toast } = useWarnToast();
|
||||
const [qcAuthority, setQcAuthority] = useState(ncr.qc_authority ?? "");
|
||||
const [workOrder, setWorkOrder] = useState(ncr.work_order ?? "");
|
||||
const [notes, setNotes] = useState(ncr.disposition_notes ?? "");
|
||||
|
||||
const mutation = useNcrMutation(
|
||||
(release: boolean) =>
|
||||
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/secondary-disposition`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
qc_authority: qcAuthority || null,
|
||||
work_order: workOrder || null,
|
||||
disposition_notes: notes || null,
|
||||
release,
|
||||
},
|
||||
}),
|
||||
warnings,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{mutation.isError && (
|
||||
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||
)}
|
||||
<TextField
|
||||
label="QC Authority"
|
||||
value={qcAuthority}
|
||||
onChange={(e) => setQcAuthority(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Work Order"
|
||||
value={workOrder}
|
||||
onChange={(e) => setWorkOrder(e.target.value)}
|
||||
/>
|
||||
<RichTextEditor label="Disposition Notes" value={notes} onChange={setNotes} />
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={mutation.isPending}
|
||||
onClick={() =>
|
||||
mutation.mutate(false, { onSuccess: () => toast("Saved.") })
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||
disabled={mutation.isPending}
|
||||
onClick={() =>
|
||||
mutation.mutate(true, {
|
||||
onSuccess: () => toast("Released to Operations."),
|
||||
})
|
||||
}
|
||||
>
|
||||
Release to Operations
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Operations ───────────────────────────────────────────────────────────────
|
||||
export function OperationsForm({ ncr }: { ncr: NcrDetail }) {
|
||||
const { warnings, toast } = useWarnToast();
|
||||
const mutation = useNcrMutation(
|
||||
() =>
|
||||
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/operations-complete`, {
|
||||
method: "POST",
|
||||
}),
|
||||
warnings,
|
||||
);
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
{mutation.isError && (
|
||||
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||
)}
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Review the disposition above, complete the rework/repair, then mark
|
||||
operations complete to send this NCR to QC Inspection.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={mutation.isPending}
|
||||
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||
onClick={() =>
|
||||
mutation.mutate(undefined as never, {
|
||||
onSuccess: () => toast("Operations complete — sent to QC Inspection."),
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark Operations Complete
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── QC Inspection ────────────────────────────────────────────────────────────
|
||||
export function InspectionForm({ ncr }: { ncr: NcrDetail }) {
|
||||
const { warnings, toast } = useWarnToast();
|
||||
const [approval, setApproval] = useState<"yes" | "no" | null>(ncr.qc_approval);
|
||||
const [notes, setNotes] = useState(ncr.inspection_notes ?? "");
|
||||
const [close, setClose] = useState(false);
|
||||
|
||||
const mutation = useNcrMutation(
|
||||
() =>
|
||||
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/inspection`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
qc_approval: approval,
|
||||
inspection_notes: notes || null,
|
||||
qc_closed: close,
|
||||
},
|
||||
}),
|
||||
warnings,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{mutation.isError && (
|
||||
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||
)}
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography>QC Approval:</Typography>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
value={approval}
|
||||
onChange={(_, v) => setApproval(v)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="yes" color="success">
|
||||
Yes
|
||||
</ToggleButton>
|
||||
<ToggleButton value="no" color="error">
|
||||
No
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Stack>
|
||||
<TextField
|
||||
label="Inspection Notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
multiline
|
||||
minRows={3}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={close} onChange={(e) => setClose(e.target.checked)} />}
|
||||
label="QC Closed — send to Costing (inspection can no longer be edited)"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={mutation.isPending}
|
||||
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||
onClick={() =>
|
||||
mutation.mutate(undefined as never, {
|
||||
onSuccess: () =>
|
||||
toast(close ? "QC closed — sent to Costing." : "Inspection saved."),
|
||||
})
|
||||
}
|
||||
>
|
||||
{close ? "Save & Close QC" : "Save Inspection"}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Costing ──────────────────────────────────────────────────────────────────
|
||||
function CostField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<TextField
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (/^\d*\.?\d{0,2}$/.test(v)) onChange(v);
|
||||
}}
|
||||
inputProps={{ inputMode: "decimal" }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
|
||||
sx={{ maxWidth: 220 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CostingForm({ ncr }: { ncr: NcrDetail }) {
|
||||
const { warnings, toast } = useWarnToast();
|
||||
const [labor, setLabor] = useState(ncr.labor_cost ?? "");
|
||||
const [material, setMaterial] = useState(ncr.material_cost ?? "");
|
||||
const [service, setService] = useState(ncr.service_cost ?? "");
|
||||
const [other, setOther] = useState(ncr.other_cost ?? "");
|
||||
|
||||
const total = useMemo(() => {
|
||||
const sum =
|
||||
(parseFloat(labor) || 0) +
|
||||
(parseFloat(material) || 0) +
|
||||
(parseFloat(service) || 0) +
|
||||
(parseFloat(other) || 0);
|
||||
return sum.toLocaleString(undefined, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
});
|
||||
}, [labor, material, service, other]);
|
||||
|
||||
const allSet = [labor, material, service, other].every((v) => v !== "");
|
||||
|
||||
const mutation = useNcrMutation(
|
||||
() =>
|
||||
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/costing`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
labor_cost: labor || "0",
|
||||
material_cost: material || "0",
|
||||
service_cost: service || "0",
|
||||
other_cost: other || "0",
|
||||
},
|
||||
}),
|
||||
warnings,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{mutation.isError && (
|
||||
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||
)}
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<CostField label="Labor Cost" value={labor} onChange={setLabor} />
|
||||
<CostField label="Material Cost" value={material} onChange={setMaterial} />
|
||||
<CostField label="Service Cost" value={service} onChange={setService} />
|
||||
<CostField label="Other Cost" value={other} onChange={setOther} />
|
||||
</Stack>
|
||||
<Typography variant="h6">Total: {total}</Typography>
|
||||
<Alert severity="info">
|
||||
Saving costs completes the workflow and closes this NCR. A closed NCR is
|
||||
locked; only an Admin can reopen it.
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!allSet || mutation.isPending}
|
||||
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||
onClick={() =>
|
||||
mutation.mutate(undefined as never, {
|
||||
onSuccess: () => toast("Costing complete — NCR closed."),
|
||||
})
|
||||
}
|
||||
>
|
||||
Save Costs & Close NCR
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Admin Reopen ─────────────────────────────────────────────────────────────
|
||||
const REOPEN_TARGETS = [
|
||||
"new_request",
|
||||
"secondary_disposition",
|
||||
"operations",
|
||||
"qc_inspection",
|
||||
"costing",
|
||||
] as const;
|
||||
|
||||
export function ReopenDialog({
|
||||
ncr,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
ncr: NcrDetail;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { warnings, toast } = useWarnToast();
|
||||
const [target, setTarget] = useState<string>("costing");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const mutation = useNcrMutation(
|
||||
() =>
|
||||
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/reopen`, {
|
||||
method: "POST",
|
||||
body: { to_stage: target, reason: reason.trim() },
|
||||
}),
|
||||
warnings,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Reopen {ncr.ncr_number}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
{mutation.isError && (
|
||||
<Alert severity="error">{(mutation.error as Error).message}</Alert>
|
||||
)}
|
||||
<TextField
|
||||
select
|
||||
label="Reopen to stage"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
>
|
||||
{REOPEN_TARGETS.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{STAGE_LABELS[s]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Reason (required, recorded in the audit trail)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
disabled={reason.trim().length < 5 || mutation.isPending}
|
||||
onClick={() =>
|
||||
mutation.mutate(undefined as never, {
|
||||
onSuccess: () => {
|
||||
toast("NCR reopened.");
|
||||
onClose();
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Reopen NCR
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
105
frontend/src/pages/admin/AdminAuditPage.tsx
Normal file
105
frontend/src/pages/admin/AdminAuditPage.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api, buildQuery } from "../../api/client";
|
||||
import type { AuditEntry } from "../../api/types";
|
||||
|
||||
interface GlobalAudit {
|
||||
items: AuditEntry[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export function AdminAuditPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [ncrNumber, setNcrNumber] = useState("");
|
||||
|
||||
const audit = useQuery({
|
||||
queryKey: ["admin-audit", page, ncrNumber],
|
||||
queryFn: () =>
|
||||
api<GlobalAudit>(
|
||||
`/api/admin/audit${buildQuery({ page, page_size: 50, ncr_number: ncrNumber })}`,
|
||||
),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Filter by NCR number"
|
||||
value={ncrNumber}
|
||||
onChange={(e) => {
|
||||
setNcrNumber(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Immutable system-wide audit trail (field-level before/after values).
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>When</TableCell>
|
||||
<TableCell>Who</TableCell>
|
||||
<TableCell>Action</TableCell>
|
||||
<TableCell>Field</TableCell>
|
||||
<TableCell>Before</TableCell>
|
||||
<TableCell>After</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(audit.data?.items ?? []).map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }}>
|
||||
{new Date(a.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>{a.user.display_name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={a.action.replace(/_/g, " ")} />
|
||||
{a.detail && (
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
{a.detail}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{a.field_name ?? ""}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 200, overflowWrap: "anywhere" }}>
|
||||
{a.old_value ?? ""}
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 200, overflowWrap: "anywhere" }}>
|
||||
{a.new_value ?? ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={audit.data?.total ?? 0}
|
||||
page={page - 1}
|
||||
onPageChange={(_, p) => setPage(p + 1)}
|
||||
rowsPerPage={50}
|
||||
rowsPerPageOptions={[50]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
145
frontend/src/pages/admin/AdminListsPage.tsx
Normal file
145
frontend/src/pages/admin/AdminListsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
frontend/src/pages/admin/AdminPage.tsx
Normal file
60
frontend/src/pages/admin/AdminPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
frontend/src/pages/admin/AdminSettingsPage.tsx
Normal file
66
frontend/src/pages/admin/AdminSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
174
frontend/src/pages/admin/AdminUsersPage.tsx
Normal 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
36
frontend/src/theme.ts
Normal 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
21
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal 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
2
frontend/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
25
frontend/vite.config.js
Normal file
25
frontend/vite.config.js
Normal 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
26
frontend/vite.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user