shoc-frontend-new/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx
Arthur Bassi a0cf7b9ef0
Feat/vite typescript migration (#16)
* 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>
2026-06-18 14:41:17 -03:00

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>
);
}