Files
pesco-ncr/frontend/src/pages/NcrDetailPage.tsx

423 lines
14 KiB
TypeScript
Raw Normal View History

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() {
// URL carries the NCR business number (e.g. /ncrs/NCR-2026-0021); numeric
// ids from older links still resolve server-side.
const { id } = useParams();
const ncrQuery = useNcr(id);
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>
);
}