mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 17:53:12 +00:00
377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import {
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
IconButton,
|
|
MenuItem,
|
|
Radio,
|
|
Stack,
|
|
TextField,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import CloseIcon from "@mui/icons-material/Close";
|
|
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
|
import {
|
|
useTaskListTemplates,
|
|
useWorkOrderVendors,
|
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
|
import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
|
|
|
interface SelectedWorkOrder {
|
|
id: string | number;
|
|
number: string;
|
|
title: string;
|
|
}
|
|
|
|
interface DispatchCreateModalProps {
|
|
workOrder: WorkOrderDetail;
|
|
onClose: () => void;
|
|
onDispatched: () => void;
|
|
}
|
|
|
|
export function DispatchCreateModal({
|
|
workOrder,
|
|
onClose,
|
|
onDispatched,
|
|
}: DispatchCreateModalProps) {
|
|
const [selectedVendorId, setSelectedVendorId] = useState<string | number | null>(null);
|
|
const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || "");
|
|
const [scheduledDate, setScheduledDate] = useState("");
|
|
const [description, setDescription] = useState(workOrder.description || "");
|
|
const [vendorSearch, setVendorSearch] = useState("");
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState("");
|
|
const [customItems, setCustomItems] = useState<string[]>([]);
|
|
const [newCustomItem, setNewCustomItem] = useState("");
|
|
const [selectedWOs, setSelectedWOs] = useState<SelectedWorkOrder[]>([
|
|
{
|
|
id: workOrder.id,
|
|
number: workOrder.internalWONumber,
|
|
title: workOrder.title,
|
|
},
|
|
]);
|
|
const [woSearch, setWoSearch] = useState("");
|
|
|
|
const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors(
|
|
workOrder.trade,
|
|
workOrder.locationZip,
|
|
);
|
|
const { data: taskTemplates = [] } = useTaskListTemplates();
|
|
const dispatchMutation = useDispatchToVendor(workOrder.id);
|
|
|
|
const { data: siteWorkOrdersData } = useWorkOrdersList({
|
|
search: "",
|
|
page: 1,
|
|
pageSize: 500,
|
|
sortBy: "number",
|
|
sortDir: "asc",
|
|
locationId: workOrder.locationId,
|
|
});
|
|
|
|
const siteWorkOrders = useMemo(
|
|
() =>
|
|
(siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)),
|
|
[siteWorkOrdersData?.items, workOrder.id],
|
|
);
|
|
|
|
const filteredVendors = useMemo(
|
|
() =>
|
|
vendors.filter(
|
|
(vendor) =>
|
|
vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
|
|
vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()),
|
|
),
|
|
[vendors, vendorSearch],
|
|
);
|
|
|
|
const filteredSiteWOs = useMemo(
|
|
() =>
|
|
siteWorkOrders
|
|
.filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id)))
|
|
.filter(
|
|
(item) =>
|
|
!woSearch ||
|
|
item.woNumber.includes(woSearch) ||
|
|
item.title.toLowerCase().includes(woSearch.toLowerCase()),
|
|
)
|
|
.slice(0, 10),
|
|
[siteWorkOrders, selectedWOs, woSearch],
|
|
);
|
|
|
|
const handleSubmit = () => {
|
|
if (!selectedVendorId) {
|
|
return;
|
|
}
|
|
|
|
dispatchMutation.mutate(
|
|
{
|
|
workOrderIds: selectedWOs.map((wo) => wo.id),
|
|
vendorIds: [selectedVendorId],
|
|
nteAmount: nteAmount ? parseFloat(nteAmount) : null,
|
|
scheduledDate: scheduledDate || null,
|
|
description,
|
|
taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null,
|
|
customChecklistItems: customItems.length > 0 ? customItems : null,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
onDispatched();
|
|
onClose();
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
|
|
<DialogTitle className="flex items-center justify-between pr-2">
|
|
Dispatch{" "}
|
|
{selectedWOs.length > 1
|
|
? `${selectedWOs.length} Work Orders`
|
|
: `Work Order ${workOrder.internalWONumber}`}
|
|
<IconButton aria-label="Close" onClick={onClose} size="small">
|
|
<CloseIcon fontSize="small" />
|
|
</IconButton>
|
|
</DialogTitle>
|
|
<DialogContent className="flex flex-col gap-4">
|
|
<Box>
|
|
<Typography variant="subtitle2" className="mb-2">
|
|
Work Orders ({selectedWOs.length})
|
|
</Typography>
|
|
<Stack
|
|
direction="row"
|
|
sx={{
|
|
flexWrap: "wrap",
|
|
gap: 1,
|
|
}}
|
|
>
|
|
{selectedWOs.map((wo) => (
|
|
<Chip
|
|
key={String(wo.id)}
|
|
label={`${wo.number} — ${wo.title}`}
|
|
onDelete={
|
|
String(wo.id) !== String(workOrder.id)
|
|
? () => setSelectedWOs((prev) => prev.filter((item) => item.id !== wo.id))
|
|
: undefined
|
|
}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
{siteWorkOrders.length > 0 && (
|
|
<Box className="mt-3">
|
|
<TextField
|
|
size="small"
|
|
value={woSearch}
|
|
onChange={(event) => setWoSearch(event.target.value)}
|
|
placeholder="Search to filter..."
|
|
fullWidth
|
|
className="mb-2"
|
|
/>
|
|
<PaperList>
|
|
{filteredSiteWOs.map((item) => (
|
|
<Box
|
|
key={String(item.id)}
|
|
className="cursor-pointer rounded px-2 py-1 hover:bg-muted/50"
|
|
onClick={() => {
|
|
setSelectedWOs((prev) => [
|
|
...prev,
|
|
{ id: item.id, number: item.woNumber, title: item.title },
|
|
]);
|
|
setWoSearch("");
|
|
}}
|
|
>
|
|
<Typography variant="body2">
|
|
<strong>{item.woNumber}</strong> — {item.title}
|
|
</Typography>
|
|
</Box>
|
|
))}
|
|
</PaperList>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
<Box>
|
|
<Typography variant="subtitle2" className="mb-2">
|
|
Select Vendor
|
|
</Typography>
|
|
<TextField
|
|
size="small"
|
|
value={vendorSearch}
|
|
onChange={(event) => setVendorSearch(event.target.value)}
|
|
placeholder="Search vendors..."
|
|
fullWidth
|
|
className="mb-2"
|
|
/>
|
|
{isLoadingVendors ? (
|
|
<Typography
|
|
sx={{
|
|
color: "text.secondary",
|
|
}}
|
|
>
|
|
Loading vendors...
|
|
</Typography>
|
|
) : filteredVendors.length === 0 ? (
|
|
<Typography
|
|
sx={{
|
|
color: "text.secondary",
|
|
}}
|
|
>
|
|
No vendors found
|
|
</Typography>
|
|
) : (
|
|
<Stack spacing={1} className="max-h-60 overflow-y-auto">
|
|
{filteredVendors.map((vendor) => (
|
|
<Box
|
|
key={String(vendor.id)}
|
|
className={`flex cursor-pointer items-start gap-2 rounded border p-2 ${
|
|
selectedVendorId === vendor.id
|
|
? "border-primary bg-primary/10"
|
|
: "border-border"
|
|
}`}
|
|
onClick={() =>
|
|
setSelectedVendorId((current) => (current === vendor.id ? null : vendor.id))
|
|
}
|
|
>
|
|
<Radio checked={selectedVendorId === vendor.id} readOnly size="small" />
|
|
<Box className="flex-1">
|
|
<Typography variant="body2" className="font-semibold">
|
|
{vendor.companyName}
|
|
</Typography>
|
|
{vendor.tradeSpecialties && (
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
color: "text.secondary",
|
|
display: "block",
|
|
}}
|
|
>
|
|
{vendor.tradeSpecialties}
|
|
</Typography>
|
|
)}
|
|
{vendor.address && (
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
color: "text.secondary",
|
|
display: "block",
|
|
}}
|
|
>
|
|
{vendor.address}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
{vendor.distanceMiles != null && (
|
|
<Typography variant="caption" className="whitespace-nowrap">
|
|
{vendor.distanceMiles} miles
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
<TextField
|
|
label="NTE Amount"
|
|
type="number"
|
|
value={nteAmount}
|
|
onChange={(event) => setNteAmount(event.target.value)}
|
|
fullWidth
|
|
slotProps={{
|
|
htmlInput: { step: "0.01", min: 0 },
|
|
}}
|
|
/>
|
|
<TextField
|
|
label="Vendor Scheduled Date"
|
|
type="date"
|
|
value={scheduledDate}
|
|
onChange={(event) => setScheduledDate(event.target.value)}
|
|
slotProps={{ inputLabel: { shrink: true } }}
|
|
fullWidth
|
|
/>
|
|
</Stack>
|
|
|
|
<TextField
|
|
label="Task List Template"
|
|
select
|
|
value={selectedTemplateId}
|
|
onChange={(event) => setSelectedTemplateId(event.target.value)}
|
|
fullWidth
|
|
>
|
|
<MenuItem value="">No checklist template</MenuItem>
|
|
{taskTemplates.map((template) => (
|
|
<MenuItem key={String(template.id)} value={String(template.id)}>
|
|
{template.name} ({template.items?.length ?? 0} items)
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
|
|
{customItems.length > 0 && (
|
|
<Stack
|
|
direction="row"
|
|
sx={{
|
|
flexWrap: "wrap",
|
|
gap: 1,
|
|
}}
|
|
>
|
|
{customItems.map((item, index) => (
|
|
<Chip
|
|
key={`${item}-${index}`}
|
|
label={item}
|
|
onDelete={() => setCustomItems((prev) => prev.filter((_, i) => i !== index))}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
|
|
<TextField
|
|
value={newCustomItem}
|
|
onChange={(event) => setNewCustomItem(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && newCustomItem.trim()) {
|
|
event.preventDefault();
|
|
setCustomItems((prev) => [...prev, newCustomItem.trim()]);
|
|
setNewCustomItem("");
|
|
}
|
|
}}
|
|
placeholder="Add custom checklist item..."
|
|
fullWidth
|
|
size="small"
|
|
/>
|
|
|
|
<TextField
|
|
label="Description"
|
|
multiline
|
|
rows={4}
|
|
value={description}
|
|
onChange={(event) => setDescription(event.target.value)}
|
|
fullWidth
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={onClose} disabled={dispatchMutation.isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleSubmit}
|
|
disabled={dispatchMutation.isPending || !selectedVendorId}
|
|
>
|
|
{dispatchMutation.isPending ? "Dispatching..." : "Dispatch"}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function PaperList({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<Box className="max-h-40 overflow-y-auto rounded border border-border p-1">{children}</Box>
|
|
);
|
|
}
|