Add API Q1 CAPA: root cause, corrective action gate, effectiveness verification

Implements tester feedback against API Q1 §5.9.1.2 / §6.4.2:

- Root Cause field + 6M Root Cause Category lookup (Man/Machine/Method/
  Material/Measurement/Environment), separate from Deviation Detail/Category
- "Corrective Action Required?" Yes/No gate on every NCR with a required
  justification
- Corrective action plan with owner + due date; owner is notified by email
- Effectiveness verification (result, notes, server-stamped verifier/date)
  required before an NCR can close when corrective action is required —
  costing returns 409 listing the missing pieces
- Recurring-issue flag with bidirectional NCR-to-NCR links; prior NCRs show
  a warning when later NCRs reference them
- Dashboard metrics: % root cause completed, % CAPA verified effective,
  avg CAPA close time, overdue CAPA count, NCRs by root cause category
- CAPA section in the NCR detail UI, printable PDF, CSV export, and the
  vw_ncr_full Power BI view; admin list manager for root cause categories
- Migrations 0003 (schema + seeded 6M lookup) and 0004 (view refresh);
  demo seed data exercises every metric

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ang3l12
2026-08-04 13:49:08 -06:00
parent 8d48f774bb
commit c1cd1bc9df
28 changed files with 1830 additions and 61 deletions

View File

@@ -35,10 +35,11 @@ export function useLookups() {
});
}
/** Pass an empty role to list every active user (e.g. CA owner picker). */
export function useUsersByRole(role: string) {
return useQuery({
queryKey: ["users", role],
queryFn: () => api<UserOut[]>(`/api/users?role=${role}`),
queryFn: () => api<UserOut[]>(role ? `/api/users?role=${role}` : "/api/users"),
staleTime: 60_000,
});
}

View File

@@ -71,6 +71,7 @@ export interface NamedLookup {
export interface LookupsOut {
departments: NamedLookup[];
deviation_categories: NamedLookup[];
root_cause_categories: NamedLookup[];
}
export interface AttachmentOut {
@@ -128,10 +129,19 @@ export type NcrAction =
| "operations_complete"
| "inspection"
| "costing"
| "capa"
| "reopen"
| "add_attachment"
| "view_audit";
export interface NcrLinkOut {
id: number;
ncr_number: string;
job_number: string;
stage: StageValue;
stage_label: string;
}
export interface NcrDetail {
id: number;
ncr_number: string;
@@ -161,6 +171,22 @@ export interface NcrDetail {
qc_closed: boolean;
qc_closed_at: string | null;
qc_closed_by: UserRef | null;
root_cause: string | null;
root_cause_category: string | null;
root_cause_category_id: number | null;
corrective_action_required: boolean | null;
corrective_action_justification: string | null;
corrective_action_plan: string | null;
corrective_action_owner: UserRef | null;
corrective_action_due_date: string | null;
corrective_action_opened_at: string | null;
effectiveness_result: "effective" | "not_effective" | null;
effectiveness_notes: string | null;
effectiveness_verified_at: string | null;
effectiveness_verified_by: UserRef | null;
is_recurring: boolean;
related_ncrs: NcrLinkOut[];
referenced_by: NcrLinkOut[];
labor_cost: string | null;
material_cost: string | null;
service_cost: string | null;
@@ -213,6 +239,11 @@ export interface ReportsSummary {
open_ncrs: number;
closed_ncrs: number;
total_cost: string;
root_cause_pct: number | null;
effectiveness_verified_pct: number | null;
avg_capa_close_days: number | null;
overdue_capa_count: number;
by_root_cause_category: { name: string; count: number }[];
by_department: { name: string; count: number }[];
by_category: { name: string; count: number }[];
by_month: { month: string; count: number }[];

View File

@@ -0,0 +1,457 @@
/** Corrective & Preventive Action section (API Q1 §5.9.1.2 / §6.4.2).
*
* Read-only summary for everyone; QC Inspectors / Disposition Authorities /
* Admins (server action "capa") get the editable form. Partial saves are
* allowed at any non-closed stage — the API enforces the closure gate when
* Costing tries to close the NCR. */
import SaveIcon from "@mui/icons-material/Save";
import {
Alert,
Autocomplete,
Button,
Checkbox,
Chip,
CircularProgress,
Divider,
FormControlLabel,
Grid,
MenuItem,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@mui/material";
import { useMemo, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { api } from "../api/client";
import { useLookups, useNcrMutation, useQueue } from "../api/hooks";
import type { NcrDetail, NcrLinkOut, NcrMutationOut, UserOut } from "../api/types";
import { FieldRow } from "../components/FieldRow";
import { useToast } from "../components/Toast";
import { UserPicker } from "../components/UserPicker";
function NcrChips({ links }: { links: NcrLinkOut[] }) {
return (
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{links.map((l) => (
<Chip
key={l.id}
size="small"
clickable
component={RouterLink}
to={`/ncrs/${l.ncr_number}`}
label={`${l.ncr_number} (${l.stage_label})`}
/>
))}
</Stack>
);
}
function EffectivenessChip({ ncr }: { ncr: NcrDetail }) {
if (ncr.effectiveness_result === "effective")
return <Chip size="small" color="success" label="Verified Effective" />;
if (ncr.effectiveness_result === "not_effective")
return <Chip size="small" color="error" label="Not Effective" />;
return <Chip size="small" variant="outlined" label="Pending Verification" />;
}
/** Mirrors the server's _capa_close_blockers so users see the closure gate
* status before Costing runs into it. */
function gateBlockers(ncr: NcrDetail): string[] {
if (ncr.corrective_action_required === null)
return ["'Corrective Action Required?' has not been answered"];
if (!ncr.corrective_action_required) return [];
const missing: string[] = [];
if (!ncr.root_cause?.trim()) missing.push("root cause");
if (!ncr.root_cause_category_id) missing.push("root cause category");
if (!ncr.corrective_action_plan?.trim()) missing.push("action plan");
if (!ncr.corrective_action_owner) missing.push("action owner");
if (!ncr.corrective_action_due_date) missing.push("due date");
if (ncr.effectiveness_result !== "effective")
missing.push("effectiveness verification (verified effective)");
return missing;
}
function GateStatus({ ncr }: { ncr: NcrDetail }) {
if (ncr.stage === "closed") return null;
const blockers = gateBlockers(ncr);
if (blockers.length === 0)
return (
<Alert severity="success" sx={{ mb: 2 }}>
CAPA section complete the closure gate is satisfied.
</Alert>
);
return (
<Alert severity={ncr.stage === "costing" ? "warning" : "info"} sx={{ mb: 2 }}>
Before this NCR can be closed: {blockers.join(", ")}.
</Alert>
);
}
function fmt(iso: string | null): string {
return iso ? new Date(iso).toLocaleString() : "—";
}
function CapaSummary({ ncr }: { ncr: NcrDetail }) {
return (
<Grid container spacing={2}>
<FieldRow label="Corrective Action Required">
{ncr.corrective_action_required === null
? "Not answered"
: ncr.corrective_action_required
? "Yes"
: "No"}
</FieldRow>
<FieldRow label="Justification">{ncr.corrective_action_justification}</FieldRow>
<FieldRow label="Root Cause Category">{ncr.root_cause_category}</FieldRow>
<FieldRow label="Action Owner">
{ncr.corrective_action_owner?.display_name}
</FieldRow>
<FieldRow label="Due Date">{ncr.corrective_action_due_date}</FieldRow>
<FieldRow label="Effectiveness">
<Stack direction="row" spacing={1} alignItems="center">
<EffectivenessChip ncr={ncr} />
{ncr.effectiveness_verified_by && (
<Typography variant="body2" color="text.secondary">
{ncr.effectiveness_verified_by.display_name},{" "}
{fmt(ncr.effectiveness_verified_at)}
</Typography>
)}
</Stack>
</FieldRow>
{ncr.root_cause && (
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary" display="block">
Root Cause
</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{ncr.root_cause}</Typography>
</Grid>
)}
{ncr.corrective_action_plan && (
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary" display="block">
Corrective Action Plan
</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>
{ncr.corrective_action_plan}
</Typography>
</Grid>
)}
{ncr.effectiveness_notes && (
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary" display="block">
Verification Notes
</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>
{ncr.effectiveness_notes}
</Typography>
</Grid>
)}
<FieldRow label="Recurring Issue">{ncr.is_recurring ? "Yes" : "No"}</FieldRow>
{ncr.related_ncrs.length > 0 && (
<Grid item xs={12} sm={6} md={8}>
<Typography variant="caption" color="text.secondary" display="block">
Linked Prior NCRs
</Typography>
<NcrChips links={ncr.related_ncrs} />
</Grid>
)}
</Grid>
);
}
/** Multi-select of other NCRs, searched by NCR/job number. */
function NcrLinkPicker({
selfId,
value,
onChange,
}: {
selfId: number;
value: NcrLinkOut[];
onChange: (v: NcrLinkOut[]) => void;
}) {
const [search, setSearch] = useState("");
const results = useQueue("all", { q: search || undefined }, 1, 10);
const options = useMemo(() => {
const items = (results.data?.items ?? [])
.filter((i) => i.id !== selfId)
.map((i) => ({
id: i.id,
ncr_number: i.ncr_number,
job_number: i.job_number,
stage: i.stage,
stage_label: i.stage_label,
}));
// Keep already-selected values valid options so MUI can render them.
const seen = new Set(items.map((i) => i.id));
return [...value.filter((v) => !seen.has(v.id)), ...items];
}, [results.data, selfId, value]);
return (
<Autocomplete
multiple
options={options}
loading={results.isLoading}
value={value}
filterOptions={(x) => x}
onChange={(_, v) => onChange(v)}
onInputChange={(_, v, reason) => {
if (reason === "input") setSearch(v);
}}
getOptionLabel={(o) => `${o.ncr_number}${o.job_number}`}
isOptionEqualToValue={(a, b) => a.id === b.id}
renderInput={(params) => (
<TextField
{...params}
label="Linked prior NCRs"
placeholder="Search by NCR or job number"
helperText="Link this NCR to earlier occurrences of the same issue."
/>
)}
/>
);
}
function CapaForm({ ncr }: { ncr: NcrDetail }) {
const { warnings, toast } = useToast();
const lookups = useLookups();
const [rootCause, setRootCause] = useState(ncr.root_cause ?? "");
const [categoryId, setCategoryId] = useState<number | "">(
ncr.root_cause_category_id ?? "",
);
const [required, setRequired] = useState<"yes" | "no" | null>(
ncr.corrective_action_required === null
? null
: ncr.corrective_action_required
? "yes"
: "no",
);
const [justification, setJustification] = useState(
ncr.corrective_action_justification ?? "",
);
const [plan, setPlan] = useState(ncr.corrective_action_plan ?? "");
const [owner, setOwner] = useState<UserOut | null>(
(ncr.corrective_action_owner as UserOut | null) ?? null,
);
const [dueDate, setDueDate] = useState(ncr.corrective_action_due_date ?? "");
const [effResult, setEffResult] = useState<"effective" | "not_effective" | null>(
ncr.effectiveness_result,
);
const [effNotes, setEffNotes] = useState(ncr.effectiveness_notes ?? "");
const [recurring, setRecurring] = useState(ncr.is_recurring);
const [links, setLinks] = useState<NcrLinkOut[]>(ncr.related_ncrs);
const justificationMissing = required !== null && !justification.trim();
const mutation = useNcrMutation(
() =>
api<NcrMutationOut>(`/api/ncrs/${ncr.id}/capa`, {
method: "POST",
body: {
root_cause: rootCause || null,
root_cause_category_id: categoryId || null,
corrective_action_required: required === null ? null : required === "yes",
corrective_action_justification: justification || null,
corrective_action_plan: plan || null,
corrective_action_owner_id: owner?.id ?? null,
corrective_action_due_date: dueDate || null,
effectiveness_result: required === "yes" ? effResult : null,
effectiveness_notes: effNotes || null,
is_recurring: recurring,
related_ncr_ids: links.map((l) => l.id),
},
}),
warnings,
);
return (
<Stack spacing={2}>
{mutation.isError && (
<Alert severity="error">{(mutation.error as Error).message}</Alert>
)}
<TextField
label="Root Cause — why did this happen?"
value={rootCause}
onChange={(e) => setRootCause(e.target.value)}
multiline
minRows={2}
helperText="Distinct from the Deviation Detail (what was found)."
/>
<TextField
select
label="Root Cause Category"
value={categoryId}
onChange={(e) =>
setCategoryId(e.target.value === "" ? "" : Number(e.target.value))
}
sx={{ maxWidth: 320 }}
>
<MenuItem value=""> Not set </MenuItem>
{(lookups.data?.root_cause_categories ?? []).map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</TextField>
<Stack direction="row" spacing={2} alignItems="center">
<Typography>Corrective Action Required?</Typography>
<ToggleButtonGroup
exclusive
value={required}
onChange={(_, v) => setRequired(v)}
size="small"
>
<ToggleButton value="yes" color="warning">
Yes
</ToggleButton>
<ToggleButton value="no" color="success">
No
</ToggleButton>
</ToggleButtonGroup>
</Stack>
<TextField
label="Justification"
value={justification}
onChange={(e) => setJustification(e.target.value)}
multiline
minRows={2}
required={required !== null}
error={justificationMissing}
helperText={
justificationMissing
? "A brief justification is required when answering the question above."
: "Why corrective action is (or is not) needed."
}
/>
{required === "yes" && (
<>
<Divider>
<Chip label="Corrective Action Plan" size="small" />
</Divider>
<TextField
label="Action Plan"
value={plan}
onChange={(e) => setPlan(e.target.value)}
multiline
minRows={3}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<div style={{ flexGrow: 1 }}>
<UserPicker
role=""
label="Action Owner"
value={owner}
onChange={(v) => setOwner((v as UserOut) ?? null)}
helperText="Owns the corrective action and receives the assignment notification."
/>
</div>
<TextField
type="date"
label="Due Date"
InputLabelProps={{ shrink: true }}
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
sx={{ minWidth: 200 }}
/>
</Stack>
<Divider>
<Chip label="Effectiveness Verification" size="small" />
</Divider>
{ncr.effectiveness_verified_by && (
<Typography variant="body2" color="text.secondary">
Last verified by {ncr.effectiveness_verified_by.display_name} on{" "}
{fmt(ncr.effectiveness_verified_at)}.
</Typography>
)}
<Stack direction="row" spacing={2} alignItems="center">
<Typography>Result:</Typography>
<ToggleButtonGroup
exclusive
value={effResult}
onChange={(_, v) => setEffResult(v)}
size="small"
>
<ToggleButton value="effective" color="success">
Effective
</ToggleButton>
<ToggleButton value="not_effective" color="error">
Not Effective
</ToggleButton>
</ToggleButtonGroup>
</Stack>
<TextField
label="Verification Notes"
value={effNotes}
onChange={(e) => setEffNotes(e.target.value)}
multiline
minRows={2}
helperText="How effectiveness was confirmed (e.g. re-inspection results, recurrence check)."
/>
</>
)}
<Divider>
<Chip label="Recurring Issue" size="small" />
</Divider>
<FormControlLabel
control={
<Checkbox
checked={recurring}
onChange={(e) => setRecurring(e.target.checked)}
/>
}
label="This is a recurring issue (seen on prior NCRs)"
/>
<NcrLinkPicker selfId={ncr.id} value={links} onChange={setLinks} />
<Button
variant="contained"
sx={{ alignSelf: "flex-start" }}
disabled={mutation.isPending || justificationMissing}
endIcon={mutation.isPending ? <CircularProgress size={16} /> : <SaveIcon />}
onClick={() =>
mutation.mutate(undefined as never, {
onSuccess: () => toast("CAPA saved."),
})
}
>
Save CAPA
</Button>
</Stack>
);
}
export function CapaSection({ ncr }: { ncr: NcrDetail }) {
const canEdit = ncr.available_actions.includes("capa");
return (
<>
<GateStatus ncr={ncr} />
<CapaSummary ncr={ncr} />
{ncr.referenced_by.length > 0 && (
<Alert severity="warning" sx={{ mt: 2 }}>
<Stack spacing={0.5}>
<span>
Later NCRs flagged this one as a recurring issue the corrective
action here may not have been effective:
</span>
<NcrChips links={ncr.referenced_by} />
</Stack>
</Alert>
)}
{canEdit && (
<>
<Divider sx={{ my: 2 }}>
<Chip label="CAPA — your action" color="primary" size="small" />
</Divider>
<CapaForm ncr={ncr} />
</>
)}
</>
);
}

View File

@@ -35,6 +35,7 @@ import { RichTextView } from "../components/RichTextView";
import { StageChip } from "../components/StageChip";
import { StageStepper } from "../components/StageStepper";
import { useToast } from "../components/Toast";
import { CapaSection } from "./CapaSection";
import {
CostingForm,
InitialDispositionForm,
@@ -262,6 +263,10 @@ function DetailBody({ ncr }: { ncr: NcrDetail }) {
)}
</SectionCard>
<SectionCard title="Corrective & Preventive Action (API Q1)">
<CapaSection ncr={ncr} />
</SectionCard>
<SectionCard title="Costing">
<Grid container spacing={2}>
<FieldRow label="Labor">{money(ncr.labor_cost)}</FieldRow>

View File

@@ -220,6 +220,31 @@ export function ReportsPage() {
/>
</Stack>
{/* CAPA metrics (API Q1 §6.4.2) */}
<Stack direction="row" spacing={1.5} sx={{ mb: 2 }} flexWrap="wrap" useFlexGap>
<StatTile
label="Root Cause Completed"
value={data.root_cause_pct !== null ? `${data.root_cause_pct}%` : "—"}
/>
<StatTile
label="CAPA Verified Effective"
value={
data.effectiveness_verified_pct !== null
? `${data.effectiveness_verified_pct}%`
: "—"
}
/>
<StatTile
label="Avg CAPA Close Time"
value={
data.avg_capa_close_days !== null
? `${data.avg_capa_close_days} days`
: "—"
}
/>
<StatTile label="Overdue CAPAs" value={String(data.overdue_capa_count)} />
</Stack>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<ChartCard title="NCRs by Month">
@@ -340,6 +365,32 @@ export function ReportsPage() {
</ChartCard>
</Grid>
<Grid item xs={12} md={6}>
<ChartCard
title="NCRs by Root Cause Category"
subheader="6M classification (API Q1)"
>
{data.by_root_cause_category.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: "center" }}>
No root cause categories recorded yet.
</Typography>
) : (
<ResponsiveContainer
width="100%"
height={Math.max(200, data.by_root_cause_category.length * 34)}
>
<BarChart data={data.by_root_cause_category} 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="Open NCR Aging" subheader="Days in current stage">
<ResponsiveContainer width="100%" height={240}>

View File

@@ -70,7 +70,7 @@ function LookupManager({
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<TextField
size="small"
label={`New ${title.toLowerCase().replace(/s$/, "")}`}
label={`New ${title.toLowerCase().replace(/ies$/, "y").replace(/s$/, "")}`}
value={newName}
onChange={(e) => setNewName(e.target.value)}
fullWidth
@@ -139,6 +139,13 @@ export function AdminListsPage() {
helper="Shown in the Deviation Category dropdown on new NCRs"
/>
</Grid>
<Grid item xs={12} md={6}>
<LookupManager
title="Root Cause Categories"
endpoint="/api/admin/root-cause-categories"
helper="6M classification used in the CAPA section (API Q1 trend reporting)"
/>
</Grid>
</Grid>
</>
);