422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|