mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 03:23:12 +00:00
- 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
352 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|