mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
* chore: add eslint, prettier, husky and commitlint tooling * ci: add GitHub Actions workflow for lint and build * build: migrate from CRA to Vite with TypeScript config * docs: add architecture plan and design system documentation * fix: scope ESLint to new components and hooks directories * feat: add HTTP client, query cache and shared utilities * feat: add theme system and global application styles * feat: add shared UI, layout and domain badge components * feat: add auth domain, provider and login page * feat: add app shell, file-based routing and bootstrap * feat: add protected layout and dashboard module * feat: add accounts CRUD module * feat: add assets CRUD module * feat: add contacts CRUD module * feat: add employees CRUD module * feat: add locations CRUD module * feat: add calendar events module * feat: add follow-ups CRUD module * feat: add PM schedules CRUD module * feat: add work orders module with dispatch modals * feat: add vendors CRUD and portal token panel * feat: add vendor purchase orders module * feat: add uplifts queue module * feat: add settings for dropdowns and task templates * feat: add vendor portal routes and signature capture * test: add Vitest setup and Playwright login e2e spec * chore: remove legacy CRA pages, Redux store and JS hooks * chore: update gitignore and env example for Vite * docs: translate pt-BR docs and Cursor rules to English * fix(ci): fix login e2e session mock and prettier formatting * fix(build): use mjs router script for Node 20 CI compatibility * chore: remove migration scripts and unused generouted router * fix(auth): restore JWT and align CRUD with backend routes * fix(api): add no-content helpers and align paths with backend * fix(accounts): align detail and mutation payloads with backend * fix(contacts): resolve detail via GetContacts and map address DTOs * fix(work-orders): align routes, delete body, and create payload * fix(vendors): delete vendors via REST route * fix(pm-schedules): handle empty save and delete responses * fix(employees): add fallbacks for detail and dropdown calls * chore(calendar): disable routes until backend exists * chore(env): switch tracked env vars to Vite prefixes * fix(api): align frontend contracts with backend review findings Correct Work Order getById query param, asset site options via Location API, Employee JobTitleId payload, and remove stale API paths. * fix(employees,pm-schedules): align forms with backend API contracts Align PM Schedule form and save payload with PMSchedule_DTO fields. Bind employee Job Title select to jobTitleId for create/update payloads. Add regression tests for both flows. * fix(pm-schedules): gate edit/delete for Dev API contract Production Dev API exposes only GetList and Save. Hide unsupported edit/delete UI and block the edit route. Add regression tests for disabled actions. * docs(env): document VITE_API_URL must include /api suffix * refactor(api): extract shared API prefix URL resolution * feat(build): fail build on misconfigured absolute VITE_API_URL * test(api): cover API URL contract and prefix resolution * feat(auth): disable login submit until email and password are valid * test(auth): align login tests with disabled submit behavior * Feat/ab/menu-and-header (#17) * chore(deps): add lucide-react for layout icon migration * feat(auth): add getPrimaryUserRole helper for header display * style(theme): add sidebar active tokens and nav group typography * refactor(menu): migrate nav icons to lucide and trim menu groups * feat(layout): redesign sidebar, topbar, and admin shell viewport * chore(menu): hide Reports and Documents from sidebar --------- Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com> * ci: add frontend PR quality baseline (#18) * chore(deps): add lucide-react for layout icon migration * feat(auth): add getPrimaryUserRole helper for header display * style(theme): add sidebar active tokens and nav group typography * refactor(menu): migrate nav icons to lucide and trim menu groups * feat(layout): redesign sidebar, topbar, and admin shell viewport * chore(menu): hide Reports and Documents from sidebar * ci: add PR quality baseline checks * ci: avoid self-matching standards guard * ci: split frontend quality gates --------- Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com> --------- Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com> Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
698 lines
26 KiB
TypeScript
698 lines
26 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import CloseIcon from "@mui/icons-material/Close";
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
Chip,
|
|
CircularProgress,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
FormControlLabel,
|
|
IconButton,
|
|
MenuItem,
|
|
Stack,
|
|
TextField,
|
|
Tooltip,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import { SignatureCapture } from "@/components/common/signature-capture";
|
|
import {
|
|
useApproveUplift,
|
|
useDenyUplift,
|
|
useUpliftsForDispatch,
|
|
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
|
import { DISPATCH_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
|
import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
|
import {
|
|
useAddChecklistItem,
|
|
useAddDispatchComment,
|
|
useAddDispatchSignoff,
|
|
useUpdateChecklistItem,
|
|
useUpdateDispatch,
|
|
useVerifyDispatch,
|
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
|
import { isDispatchLocked } from "@/domain/work-orders/utils/is-dispatch-locked";
|
|
import { useAuthContext } from "@/providers/auth-context";
|
|
import {
|
|
formatDateTime,
|
|
timeBetween,
|
|
timeSince,
|
|
toDateInputValue,
|
|
waitTimeColor,
|
|
} from "@/lib/time-utils";
|
|
|
|
interface DispatchDetailModalProps {
|
|
dispatchId: string | number;
|
|
workOrderId: string | number;
|
|
onClose: () => void;
|
|
onUpdated: () => void;
|
|
}
|
|
|
|
export function DispatchDetailModal({
|
|
dispatchId,
|
|
workOrderId,
|
|
onClose,
|
|
onUpdated,
|
|
}: DispatchDetailModalProps) {
|
|
const { user } = useAuthContext();
|
|
const { data: dispatch, isLoading } = useDispatchDetail(dispatchId);
|
|
const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId);
|
|
|
|
const [editing, setEditing] = useState(false);
|
|
const [form, setForm] = useState({
|
|
status: "",
|
|
nteAmount: "",
|
|
scheduledDate: "",
|
|
completedDate: "",
|
|
description: "",
|
|
});
|
|
const [newChecklistItem, setNewChecklistItem] = useState("");
|
|
const [commentText, setCommentText] = useState("");
|
|
const [sendEmail, setSendEmail] = useState(true);
|
|
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
|
|
|
|
const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
|
|
const addCommentMutation = useAddDispatchComment(dispatchId);
|
|
const updateChecklistMutation = useUpdateChecklistItem(dispatchId);
|
|
const addChecklistMutation = useAddChecklistItem(dispatchId);
|
|
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
|
|
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
|
|
const approveUpliftMutation = useApproveUplift(dispatchId);
|
|
const denyUpliftMutation = useDenyUplift(dispatchId);
|
|
|
|
const userName =
|
|
(typeof user?.name === "string" ? user.name : undefined) ??
|
|
(typeof user?.fullname === "string" ? user.fullname : undefined) ??
|
|
"User";
|
|
|
|
const beginEditing = () => {
|
|
if (dispatch) {
|
|
setForm({
|
|
status: dispatch.status || "",
|
|
nteAmount: dispatch.nteAmount != null ? String(dispatch.nteAmount) : "",
|
|
scheduledDate: toDateInputValue(dispatch.scheduledDate),
|
|
completedDate: toDateInputValue(dispatch.completedDate),
|
|
description: dispatch.description || "",
|
|
});
|
|
}
|
|
setEditing(true);
|
|
};
|
|
|
|
const verifyState = useMemo(() => {
|
|
if (!dispatch) {
|
|
return { canVerify: false, missing: [] as string[] };
|
|
}
|
|
|
|
const checklistTotal = dispatch.checklistItems.length;
|
|
const checklistDone = dispatch.checklistItems.filter((item) => item.isCompleted).length;
|
|
const signoffCount = dispatch.signoffs.length;
|
|
const missing: string[] = [];
|
|
|
|
if (checklistTotal > 0 && checklistDone < checklistTotal) {
|
|
missing.push(`${checklistTotal - checklistDone} checklist item(s)`);
|
|
}
|
|
|
|
if (signoffCount < 2) {
|
|
const types = dispatch.signoffs.map((signoff) => signoff.signoffType);
|
|
if (!types.includes("Customer")) {
|
|
missing.push("Customer sign-off");
|
|
}
|
|
if (!types.includes("Vendor")) {
|
|
missing.push("Vendor sign-off");
|
|
}
|
|
}
|
|
|
|
return {
|
|
missing,
|
|
canVerify: missing.length === 0 && (checklistTotal > 0 || signoffCount >= 2),
|
|
};
|
|
}, [dispatch]);
|
|
|
|
const handleSave = () => {
|
|
updateDispatchMutation.mutate(
|
|
{
|
|
id: dispatchId,
|
|
status: form.status,
|
|
nteAmount: form.nteAmount ? parseFloat(form.nteAmount) : null,
|
|
scheduledDate: form.scheduledDate || null,
|
|
completedDate: form.completedDate || null,
|
|
description: form.description,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
setEditing(false);
|
|
onUpdated();
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const handleComment = (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
if (!commentText.trim()) {
|
|
return;
|
|
}
|
|
|
|
addCommentMutation.mutate(
|
|
{ text: commentText.trim(), sendEmail },
|
|
{
|
|
onSuccess: () => setCommentText(""),
|
|
},
|
|
);
|
|
};
|
|
|
|
const handleApproveUplift = (upliftId: string | number, requestedNTE: number) => {
|
|
const note = window.prompt("Optional approval note:", "") || "";
|
|
if (!window.confirm(`Approve uplift to $${requestedNTE.toFixed(2)}?`)) {
|
|
return;
|
|
}
|
|
|
|
approveUpliftMutation.mutate({ id: upliftId, note }, { onSuccess: onUpdated });
|
|
};
|
|
|
|
const handleDenyUplift = (upliftId: string | number) => {
|
|
const note = window.prompt("Denial reason (required):", "");
|
|
if (!note?.trim()) {
|
|
return;
|
|
}
|
|
|
|
denyUpliftMutation.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onUpdated });
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
|
|
<DialogContent className="flex justify-center py-12">
|
|
<CircularProgress />
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
if (!dispatch) {
|
|
return null;
|
|
}
|
|
|
|
const isLocked = isDispatchLocked(dispatch);
|
|
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
|
|
|
return (
|
|
<>
|
|
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper">
|
|
<DialogTitle className="flex items-start justify-between gap-4 pr-2">
|
|
<Box>
|
|
<Typography variant="h6">
|
|
{dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
PO: {dispatch.poNumber} ·{" "}
|
|
{dispatch.workOrders.length > 0
|
|
? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}`
|
|
: `WO: ${dispatch.woNumber}`}{" "}
|
|
· {dispatch.vendorEmail}
|
|
</Typography>
|
|
</Box>
|
|
<IconButton aria-label="Close" onClick={onClose} size="small">
|
|
<CloseIcon fontSize="small" />
|
|
</IconButton>
|
|
</DialogTitle>
|
|
|
|
<DialogContent className="flex flex-col gap-6">
|
|
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
{editing ? (
|
|
<>
|
|
<TextField
|
|
label="Status"
|
|
select
|
|
size="small"
|
|
value={form.status}
|
|
onChange={(event) =>
|
|
setForm((current) => ({ ...current, status: event.target.value }))
|
|
}
|
|
>
|
|
{DISPATCH_STATUS_OPTIONS.map((status) => (
|
|
<MenuItem key={status} value={status}>
|
|
{status}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
<TextField
|
|
label="NTE Amount"
|
|
type="number"
|
|
size="small"
|
|
value={form.nteAmount}
|
|
onChange={(event) =>
|
|
setForm((current) => ({ ...current, nteAmount: event.target.value }))
|
|
}
|
|
inputProps={{ step: "0.01" }}
|
|
/>
|
|
<TextField
|
|
label="Scheduled Date"
|
|
type="date"
|
|
size="small"
|
|
value={form.scheduledDate}
|
|
onChange={(event) =>
|
|
setForm((current) => ({ ...current, scheduledDate: event.target.value }))
|
|
}
|
|
slotProps={{ inputLabel: { shrink: true } }}
|
|
/>
|
|
<TextField
|
|
label="Completed Date"
|
|
type="date"
|
|
size="small"
|
|
value={form.completedDate}
|
|
onChange={(event) =>
|
|
setForm((current) => ({ ...current, completedDate: event.target.value }))
|
|
}
|
|
slotProps={{ inputLabel: { shrink: true } }}
|
|
/>
|
|
<TextField
|
|
label="Description"
|
|
multiline
|
|
rows={3}
|
|
size="small"
|
|
className="sm:col-span-2"
|
|
value={form.description}
|
|
onChange={(event) =>
|
|
setForm((current) => ({ ...current, description: event.target.value }))
|
|
}
|
|
/>
|
|
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleSave}
|
|
disabled={updateDispatchMutation.isPending}
|
|
>
|
|
{updateDispatchMutation.isPending ? "Saving..." : "Save"}
|
|
</Button>
|
|
<Button onClick={() => setEditing(false)}>Cancel</Button>
|
|
</Stack>
|
|
</>
|
|
) : (
|
|
<>
|
|
<DetailField label="Status" value={dispatch.status} />
|
|
<DetailField
|
|
label="NTE"
|
|
value={dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
|
/>
|
|
<DetailField
|
|
label="Scheduled"
|
|
value={toDateInputValue(dispatch.scheduledDate) || "—"}
|
|
/>
|
|
<DetailField
|
|
label="Completed"
|
|
value={toDateInputValue(dispatch.completedDate) || "—"}
|
|
/>
|
|
<DetailField
|
|
label="Dispatched"
|
|
value={dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
|
/>
|
|
<DetailField
|
|
label="Accepted"
|
|
value={
|
|
dispatch.acknowledgedAt
|
|
? `${formatDateTime(dispatch.acknowledgedAt)} (after ${timeBetween(dispatch.dispatchedAt, dispatch.acknowledgedAt)})`
|
|
: dispatch.status === "Sent"
|
|
? `Awaiting acceptance · ${timeSince(dispatch.dispatchedAt)}`
|
|
: "—"
|
|
}
|
|
highlight={
|
|
dispatch.status === "Sent" && !dispatch.acknowledgedAt ? waitColor : undefined
|
|
}
|
|
/>
|
|
<DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} />
|
|
{dispatch.description && (
|
|
<DetailField
|
|
label="Description"
|
|
value={dispatch.description}
|
|
className="sm:col-span-2"
|
|
/>
|
|
)}
|
|
{!isLocked && (
|
|
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
|
<Button
|
|
color="error"
|
|
variant="outlined"
|
|
onClick={() => {
|
|
if (!window.confirm("Are you sure you want to cancel this dispatch?")) {
|
|
return;
|
|
}
|
|
updateDispatchMutation.mutate(
|
|
{
|
|
id: dispatchId,
|
|
status: "Cancelled",
|
|
nteAmount: dispatch.nteAmount,
|
|
scheduledDate: toDateInputValue(dispatch.scheduledDate) || null,
|
|
completedDate: toDateInputValue(dispatch.completedDate) || null,
|
|
description: dispatch.description,
|
|
},
|
|
{ onSuccess: onUpdated },
|
|
);
|
|
}}
|
|
>
|
|
Cancel Dispatch
|
|
</Button>
|
|
<Button variant="contained" onClick={beginEditing}>
|
|
Edit
|
|
</Button>
|
|
</Stack>
|
|
)}
|
|
</>
|
|
)}
|
|
</Box>
|
|
|
|
{(dispatch.checklistItems.length > 0 || !isLocked) && (
|
|
<Box>
|
|
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
|
Checklist ({dispatch.checklistItems.filter((item) => item.isCompleted).length}/
|
|
{dispatch.checklistItems.length})
|
|
</Typography>
|
|
<Stack spacing={1}>
|
|
{dispatch.checklistItems.map((item) => (
|
|
<Stack key={String(item.id)} direction="row" spacing={1} alignItems="center">
|
|
<Checkbox
|
|
checked={item.isCompleted}
|
|
disabled={isLocked || updateChecklistMutation.isPending}
|
|
onChange={() =>
|
|
updateChecklistMutation.mutate({
|
|
itemId: item.id,
|
|
isCompleted: !item.isCompleted,
|
|
completedBy: userName,
|
|
})
|
|
}
|
|
/>
|
|
<Box className="flex-1">
|
|
<Typography variant="body2">{item.itemText}</Typography>
|
|
{item.isCompleted && (
|
|
<Typography variant="caption" color="text.secondary">
|
|
{item.completedBy}
|
|
{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
{!isLocked && (
|
|
<TextField
|
|
size="small"
|
|
value={newChecklistItem}
|
|
onChange={(event) => setNewChecklistItem(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && newChecklistItem.trim()) {
|
|
event.preventDefault();
|
|
addChecklistMutation.mutate(newChecklistItem.trim(), {
|
|
onSuccess: () => setNewChecklistItem(""),
|
|
});
|
|
}
|
|
}}
|
|
placeholder="Add checklist item..."
|
|
fullWidth
|
|
className="mt-2"
|
|
/>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
<Box>
|
|
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
|
Sign-offs
|
|
</Typography>
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
{(["Customer", "Vendor"] as const).map((type) => {
|
|
const signoff = dispatch.signoffs.find((item) => item.signoffType === type);
|
|
return (
|
|
<Box key={type} className="flex-1 rounded border border-border p-3">
|
|
<Typography variant="subtitle2">{type}</Typography>
|
|
{signoff ? (
|
|
<Box className="mt-2">
|
|
{signoff.signatureMethod === "drawn" && signoff.signature && (
|
|
<img
|
|
src={signoff.signature}
|
|
alt="Signature"
|
|
className="mb-2 max-h-20 max-w-full"
|
|
/>
|
|
)}
|
|
{signoff.signatureMethod === "typed" && (
|
|
<Typography variant="h6" className="italic">
|
|
{signoff.signature}
|
|
</Typography>
|
|
)}
|
|
<Typography variant="caption" color="text.secondary">
|
|
{signoff.name}
|
|
{signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""}
|
|
</Typography>
|
|
</Box>
|
|
) : (
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
className="mt-2"
|
|
onClick={() => setSigCaptureType(type)}
|
|
disabled={isLocked}
|
|
>
|
|
{isLocked ? "Not signed" : "Collect Signature"}
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Stack>
|
|
{dispatch.signoffs.length >= 2 && (
|
|
<Chip label="All Signed ✓" color="success" size="small" className="mt-2" />
|
|
)}
|
|
</Box>
|
|
|
|
<Box>
|
|
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
|
NTE Uplift Requests
|
|
</Typography>
|
|
{uplifts.length === 0 ? (
|
|
<Typography color="text.secondary">No uplift requests.</Typography>
|
|
) : (
|
|
<Stack spacing={2}>
|
|
{uplifts.map((uplift) => {
|
|
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
|
|
|
|
return (
|
|
<Box key={String(uplift.id)} className="rounded border border-border p-3">
|
|
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
|
<Chip label={uplift.status} size="small" />
|
|
{uplift.requiredTier === 2 && (
|
|
<Typography variant="caption">Tier 2 · Manager approval</Typography>
|
|
)}
|
|
<Typography variant="body2">
|
|
${Number(uplift.currentNTE ?? 0).toFixed(2)} → $
|
|
{Number(uplift.requestedNTE).toFixed(2)} (+$
|
|
{Number(uplift.delta).toFixed(2)})
|
|
</Typography>
|
|
</Stack>
|
|
{uplift.vendorReason && (
|
|
<Typography variant="body2" className="mt-1">
|
|
<strong>Reason:</strong> {uplift.vendorReason}
|
|
</Typography>
|
|
)}
|
|
{(uplift.requestedByVendorName || uplift.requestedAt) && (
|
|
<Typography variant="body2" color="text.secondary" className="mt-1">
|
|
Requested by {uplift.requestedByVendorName || "vendor"}
|
|
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
|
|
</Typography>
|
|
)}
|
|
{uplift.status !== "Pending" &&
|
|
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
|
|
<Box className="mt-1">
|
|
{(uplift.decidedByName || uplift.decidedAt) && (
|
|
<Typography variant="body2" color="text.secondary">
|
|
{uplift.status} by {uplift.decidedByName || "—"}
|
|
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
|
</Typography>
|
|
)}
|
|
{uplift.decisionNote && (
|
|
<Typography variant="body2" className="mt-0.5">
|
|
<strong>Note:</strong> {uplift.decisionNote}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
)}
|
|
{uplift.status === "Pending" && (
|
|
<Stack direction="row" spacing={1} className="mt-2">
|
|
<Tooltip
|
|
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
|
|
>
|
|
<span>
|
|
<Button
|
|
size="small"
|
|
color="success"
|
|
variant="contained"
|
|
disabled={!canDecide}
|
|
onClick={() => handleApproveUplift(uplift.id, uplift.requestedNTE)}
|
|
>
|
|
Approve
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
<Tooltip
|
|
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
|
|
>
|
|
<span>
|
|
<Button
|
|
size="small"
|
|
color="error"
|
|
variant="contained"
|
|
disabled={!canDecide}
|
|
onClick={() => handleDenyUplift(uplift.id)}
|
|
>
|
|
Deny
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
|
|
<Box>
|
|
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
|
Vendor Communication
|
|
</Typography>
|
|
<Box className="mb-3 max-h-48 overflow-y-auto rounded border border-border p-2">
|
|
{dispatch.comments.length === 0 ? (
|
|
<Typography color="text.secondary">No messages yet.</Typography>
|
|
) : (
|
|
<Stack spacing={2}>
|
|
{dispatch.comments.map((comment) => (
|
|
<Box key={String(comment.id)}>
|
|
<Stack direction="row" justifyContent="space-between">
|
|
<Typography variant="caption" className="font-semibold">
|
|
{comment.userName || comment.commenter || "System"}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
|
|
</Typography>
|
|
</Stack>
|
|
<Typography variant="body2">{comment.commenttext}</Typography>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
<Box component="form" onSubmit={handleComment}>
|
|
<TextField
|
|
multiline
|
|
rows={2}
|
|
value={commentText}
|
|
onChange={(event) => setCommentText(event.target.value)}
|
|
placeholder="Message vendor..."
|
|
fullWidth
|
|
disabled={isLocked || addCommentMutation.isPending}
|
|
/>
|
|
<Stack
|
|
direction="row"
|
|
justifyContent="space-between"
|
|
alignItems="center"
|
|
className="mt-2"
|
|
>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={sendEmail}
|
|
onChange={(event) => setSendEmail(event.target.checked)}
|
|
disabled={isLocked}
|
|
/>
|
|
}
|
|
label="Send email"
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
variant="contained"
|
|
disabled={isLocked || addCommentMutation.isPending || !commentText.trim()}
|
|
>
|
|
{addCommentMutation.isPending ? "Sending..." : "Send"}
|
|
</Button>
|
|
</Stack>
|
|
</Box>
|
|
</Box>
|
|
|
|
{dispatch.verifiedAt ? (
|
|
<Alert severity="success">
|
|
Verified by {dispatch.verifiedBy} on {formatDateTime(dispatch.verifiedAt)}
|
|
</Alert>
|
|
) : (
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
{verifyState.missing.length > 0 && (
|
|
<Typography variant="body2" color="text.secondary">
|
|
Missing: {verifyState.missing.join(", ")}
|
|
</Typography>
|
|
)}
|
|
<Button
|
|
variant="contained"
|
|
color="success"
|
|
disabled={!verifyState.canVerify || verifyMutation.isPending}
|
|
onClick={() => verifyMutation.mutate(undefined, { onSuccess: onUpdated })}
|
|
>
|
|
Verify & Close
|
|
</Button>
|
|
</Stack>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{sigCaptureType && (
|
|
<SignatureCapture
|
|
title={`${sigCaptureType} Sign-off`}
|
|
onClose={() => setSigCaptureType(null)}
|
|
onSave={(signature, method, name) => {
|
|
addSignoffMutation.mutate(
|
|
{
|
|
signoffType: sigCaptureType,
|
|
name,
|
|
signature,
|
|
signatureMethod: method,
|
|
},
|
|
{ onSuccess: () => setSigCaptureType(null) },
|
|
);
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DetailField({
|
|
label,
|
|
value,
|
|
className,
|
|
highlight,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
className?: string;
|
|
highlight?: "green" | "yellow" | "red" | "";
|
|
}) {
|
|
const colorClass =
|
|
highlight === "green"
|
|
? "text-green-600"
|
|
: highlight === "yellow"
|
|
? "text-yellow-600"
|
|
: highlight === "red"
|
|
? "text-red-600"
|
|
: undefined;
|
|
|
|
return (
|
|
<Box className={className}>
|
|
<Typography variant="caption" color="text.secondary" display="block">
|
|
{label}
|
|
</Typography>
|
|
<Typography variant="body2" className={colorClass}>
|
|
{value}
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
}
|