shoc-frontend-new/src/app/(protected)/vendor-pos/[id].tsx
Arthur Bassi a3e8a533b1 fix(ui): align shared board plumbing with ky v2 and react-router
- api.ts: use ky v2 hook object args, prefix (not prefixUrl), and normalizeApiRequestError
- replace react-router-dom with react-router in layout, topbar, and vendor-pos view
- move MUI style props (justifyContent, alignItems, flexWrap, display, fontWeight) into sx
- resource-list-table/query-key: use local types instead of missing work-order-list module
2026-07-17 13:31:56 -03:00

352 lines
12 KiB
TypeScript

import { useState, type ReactNode } from "react";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import { formatDateTime, toDateInputValue } from "@/lib/time-utils";
function formatMoney(value: number | null | undefined): string {
if (value == null) return "—";
return `$${Number(value).toFixed(2)}`;
}
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{label}
</Typography>
<Typography variant="body2">
{children == null || children === "" ? "—" : children}
</Typography>
</Box>
);
}
export default function VendorPoViewPage() {
const { id } = useParams();
const navigate = useNavigate();
const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id);
const approveUplift = useApproveUplift(id);
const denyUplift = useDenyUplift(id);
const [upliftAction, setUpliftAction] = useState<{
type: "approve" | "deny";
uplift: UpliftRequest;
} | null>(null);
const [note, setNote] = useState("");
const handleUpliftConfirm = () => {
if (!upliftAction) return;
if (upliftAction.type === "approve") {
approveUplift.mutate(
{ id: upliftAction.uplift.id, note: note || undefined },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
} else {
if (!note.trim()) return;
denyUplift.mutate(
{ id: upliftAction.uplift.id, note: note.trim() },
{
onSuccess: () => {
setUpliftAction(null);
setNote("");
void refetch();
},
},
);
}
};
if (isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
if (error || !dispatch) {
return (
<Box className="p-4">
<Alert severity="error">
{error instanceof Error ? error.message : "Vendor PO not found"}
</Alert>
</Box>
);
}
const vendor = dispatch.vendor;
return (
<Box className="mx-auto max-w-5xl p-4">
<Stack
direction="row"
className="mb-4"
sx={{ justifyContent: "space-between", alignItems: "center" }}
>
<Button variant="text" onClick={() => navigate(-1)}>
← Back
</Button>
<Button variant="outlined" onClick={() => window.print()}>
Print
</Button>
</Stack>
<Stack spacing={1} className="mb-4">
<Typography variant="h5">{dispatch.poNumber || "(no PO #)"}</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">
Dispatch {dispatch.dispatchNumber || "—"}
</Typography>
<Chip size="small" label={dispatch.status || "—"} />
{vendor?.companyName && (
<Typography variant="body2" color="text.secondary">
· {vendor.companyName}
</Typography>
)}
{dispatch.nteAmount != null && (
<Typography variant="body2" color="text.secondary">
· NTE {formatMoney(dispatch.nteAmount)}
</Typography>
)}
</Stack>
</Stack>
<Stack spacing={3}>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Vendor
</Typography>
{vendor ? (
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<InfoRow label="Company">{vendor.companyName}</InfoRow>
<InfoRow label="Contact">{vendor.contactName}</InfoRow>
<InfoRow label="Email">{vendor.email}</InfoRow>
<InfoRow label="Phone">{vendor.phone}</InfoRow>
<InfoRow label="Address">
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
</InfoRow>
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
</Box>
) : (
<Typography color="text.secondary">No vendor attached.</Typography>
)}
</Paper>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
PO Details
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<InfoRow label="PO #">{dispatch.poNumber}</InfoRow>
<InfoRow label="Dispatch #">{dispatch.dispatchNumber}</InfoRow>
<InfoRow label="Status">{dispatch.status}</InfoRow>
<InfoRow label="NTE">{formatMoney(dispatch.nteAmount)}</InfoRow>
<InfoRow label="Dispatched">{formatDateTime(dispatch.dispatchedAt)}</InfoRow>
<InfoRow label="Acknowledged">{formatDateTime(dispatch.acknowledgedAt)}</InfoRow>
<InfoRow label="Scheduled">{toDateInputValue(dispatch.scheduledDate) || "—"}</InfoRow>
<InfoRow label="Completed">{toDateInputValue(dispatch.completedDate) || "—"}</InfoRow>
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
</Box>
{dispatch.description && (
<Box className="mt-3">
<Typography variant="caption" color="text.secondary">
Description
</Typography>
<Typography variant="body2" className="whitespace-pre-wrap">
{dispatch.description}
</Typography>
</Box>
)}
</Paper>
{dispatch.primaryLocation && (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Site
</Typography>
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoRow label="Location">{dispatch.primaryLocation.name}</InfoRow>
<InfoRow label="Address">
{[
dispatch.primaryLocation.address,
dispatch.primaryLocation.city,
dispatch.primaryLocation.state,
dispatch.primaryLocation.zipCode,
]
.filter(Boolean)
.join(", ")}
</InfoRow>
</Box>
</Paper>
)}
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Linked Work Orders ({dispatch.workOrders.length})
</Typography>
{dispatch.workOrders.length === 0 ? (
<Typography color="text.secondary">No work orders linked.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>WO #</TableCell>
<TableCell>Title</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Status</TableCell>
<TableCell>Location</TableCell>
</TableRow>
</TableHead>
<TableBody>
{dispatch.workOrders.map((wo) => (
<TableRow key={String(wo.workOrderId)}>
<TableCell>
<Button
variant="text"
size="small"
onClick={() => navigate(`/workorders/${wo.workOrderId}`)}
>
{wo.internalWONumber || wo.workOrderId}
</Button>
</TableCell>
<TableCell>{wo.title}</TableCell>
<TableCell>{wo.trade}</TableCell>
<TableCell>{wo.status}</TableCell>
<TableCell>{wo.locationName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Paper>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
NTE Uplift History ({dispatch.upliftRequests.length})
</Typography>
{dispatch.upliftRequests.length === 0 ? (
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
) : (
<Stack spacing={2}>
{dispatch.upliftRequests.map((u) => (
<Box key={String(u.id)} className="rounded border border-border p-3">
<Stack
direction="row"
sx={{ justifyContent: "space-between", alignItems: "center" }}
>
<Typography variant="body2" className="font-semibold">
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
<Typography
component="span"
variant="caption"
color="text.secondary"
className="ml-2"
>
(Δ {formatMoney(u.delta)}, Tier {u.requiredTier})
</Typography>
</Typography>
<Chip size="small" label={u.status} />
</Stack>
{u.vendorReason && (
<Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {u.vendorReason}
</Typography>
)}
{u.status === "Pending" && (
<Stack direction="row" spacing={1} className="mt-2">
<Button
size="small"
variant="contained"
color="success"
onClick={() => {
setUpliftAction({ type: "approve", uplift: u });
setNote("");
}}
>
Approve
</Button>
<Button
size="small"
variant="contained"
color="error"
onClick={() => {
setUpliftAction({ type: "deny", uplift: u });
setNote("");
}}
>
Deny
</Button>
</Stack>
)}
</Box>
))}
</Stack>
)}
</Paper>
</Stack>
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
<DialogTitle>
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}
</DialogTitle>
<DialogContent>
<TextField
fullWidth
multiline
minRows={2}
className="mt-2"
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
value={note}
onChange={(e) => setNote(e.target.value)}
required={upliftAction?.type === "deny"}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
<Button
variant="contained"
color={upliftAction?.type === "approve" ? "success" : "error"}
disabled={
upliftAction?.type === "deny"
? !note.trim() || denyUplift.isPending
: approveUplift.isPending
}
onClick={handleUpliftConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
</Box>
);
}