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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user