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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }[];
|
||||
|
||||
457
frontend/src/pages/CapaSection.tsx
Normal file
457
frontend/src/pages/CapaSection.tsx
Normal 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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user