mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
- Create migration for booking_requests table with necessary fields and indexes. - Implement BookingRequestRepository for database operations related to booking requests. - Develop BookingRequestService to handle business logic for submitting, accepting, rejecting, and canceling booking requests. - Create DTOs for creating booking requests and reviewing them. - Define BookingRequest entity to map to the booking_requests table. - Add UI components for managing shipment requests, including detail and list pages. - Implement OperationDatePicker component for selecting available shipment days.
248 lines
7.3 KiB
TypeScript
248 lines
7.3 KiB
TypeScript
import { useState } from "react";
|
||
import { useNavigate, useParams } from "react-router-dom";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
Alert,
|
||
Badge,
|
||
Box,
|
||
Button,
|
||
Group,
|
||
Loader,
|
||
Modal,
|
||
Stack,
|
||
Text,
|
||
Textarea,
|
||
} from "@mantine/core";
|
||
import {
|
||
AlertCircle,
|
||
CalendarDays,
|
||
PackagePlus,
|
||
XCircle,
|
||
} from "lucide-react";
|
||
import type { Freight } from "@edr/types";
|
||
|
||
import { PageContainer } from "@/components/page/PageContainer";
|
||
import { PageHeader } from "@/components/page/PageHeader";
|
||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||
import { contractsService } from "@/services/contracts.service";
|
||
|
||
const fmtDate = (iso?: string | null) =>
|
||
iso
|
||
? new Intl.DateTimeFormat("en-GB", {
|
||
weekday: "short",
|
||
day: "2-digit",
|
||
month: "short",
|
||
year: "numeric",
|
||
}).format(new Date(iso))
|
||
: "—";
|
||
|
||
function lineRows(lines: Freight.RequestedShipmentLines) {
|
||
if (lines.containers?.length) {
|
||
return lines.containers.map(
|
||
(c) =>
|
||
`${c.quantity} × ${c.containerSize}` +
|
||
(c.hazardousQuantity ? ` · ${c.hazardousQuantity} hazardous` : "") +
|
||
(c.reeferQuantity ? ` · ${c.reeferQuantity} reefer` : ""),
|
||
);
|
||
}
|
||
if (lines.bulk) {
|
||
const b = lines.bulk;
|
||
const parts: string[] = [];
|
||
if (b.cargoWeightTons) parts.push(`${b.cargoWeightTons} tons`);
|
||
if (b.itemCount) parts.push(`${b.itemCount} items`);
|
||
if (b.hazardousQuantity) parts.push(`${b.hazardousQuantity} hazardous`);
|
||
return [parts.join(" · ") || "Bulk cargo"];
|
||
}
|
||
return ["—"];
|
||
}
|
||
|
||
export default function ShipmentRequestDetailPage() {
|
||
const { id: reqId } = useParams<{ id: string }>();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const [rejectOpen, setRejectOpen] = useState(false);
|
||
const [rejectNote, setRejectNote] = useState("");
|
||
|
||
const { data: request, isLoading } = useQuery({
|
||
queryKey: ["shipment-request", reqId],
|
||
queryFn: () => contractsService.getBookingRequest(reqId!),
|
||
enabled: Boolean(reqId),
|
||
});
|
||
|
||
const reject = useMutation({
|
||
mutationFn: () => contractsService.rejectBookingRequest(reqId!, rejectNote),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["shipment-request-queue"] });
|
||
navigate("/dashboard/shipment-requests");
|
||
},
|
||
});
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<PageContainer>
|
||
<Group justify="center" py={80}>
|
||
<Loader color="edr-green" />
|
||
</Group>
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
if (!request) {
|
||
return (
|
||
<PageContainer>
|
||
<PageHeader title="Request not found" backTo="/dashboard/shipment-requests" />
|
||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||
We couldn't load this shipment request.
|
||
</Alert>
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
const isPending = request.status === "PENDING";
|
||
const contractRef = request.contract?.reference ?? request.contractId;
|
||
|
||
return (
|
||
<PageContainer>
|
||
<Stack gap="lg">
|
||
<PageHeader
|
||
title={`Shipment request ${request.reference}`}
|
||
subtitle={`On contract ${contractRef}`}
|
||
backTo="/dashboard/shipment-requests"
|
||
breadcrumbs={[
|
||
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" },
|
||
{ label: request.reference },
|
||
]}
|
||
meta={
|
||
<Badge
|
||
variant="light"
|
||
radius="sm"
|
||
color={
|
||
request.status === "PENDING"
|
||
? "edr-green"
|
||
: request.status === "ACCEPTED"
|
||
? "blue"
|
||
: "gray"
|
||
}
|
||
>
|
||
{request.status}
|
||
</Badge>
|
||
}
|
||
action={
|
||
isPending ? (
|
||
<Group gap="sm">
|
||
<Button
|
||
variant="light"
|
||
color="red"
|
||
radius="md"
|
||
leftSection={<XCircle size={16} />}
|
||
onClick={() => setRejectOpen(true)}
|
||
>
|
||
Reject
|
||
</Button>
|
||
<Button
|
||
color="edr-green"
|
||
radius="md"
|
||
leftSection={<PackagePlus size={16} />}
|
||
onClick={() =>
|
||
navigate(
|
||
`/dashboard/contracts/${request.contractId}/create-booking?requestId=${request.id}`,
|
||
)
|
||
}
|
||
>
|
||
Accept & create booking
|
||
</Button>
|
||
</Group>
|
||
) : request.status === "ACCEPTED" && request.createdBookingId ? (
|
||
<Button
|
||
variant="light"
|
||
color="edr-green"
|
||
radius="md"
|
||
onClick={() =>
|
||
navigate(`/dashboard/clearance/${request.createdBookingId}`)
|
||
}
|
||
>
|
||
View booking clearance
|
||
</Button>
|
||
) : undefined
|
||
}
|
||
/>
|
||
|
||
<SectionCard icon={CalendarDays} title="Requested shipment">
|
||
<Stack gap="sm">
|
||
<Group justify="space-between">
|
||
<Text size="sm" c="dimmed">
|
||
Preferred date (informational)
|
||
</Text>
|
||
<Text size="sm" fw={600}>
|
||
{fmtDate(request.scheduledDate)}
|
||
</Text>
|
||
</Group>
|
||
<Box>
|
||
<Text size="sm" c="dimmed" mb={6}>
|
||
Quantities
|
||
</Text>
|
||
<Stack gap={4}>
|
||
{lineRows(request.requestedLines ?? {}).map((l, i) => (
|
||
<Badge
|
||
key={i}
|
||
variant="light"
|
||
color="edr-green"
|
||
radius="sm"
|
||
size="lg"
|
||
>
|
||
{l}
|
||
</Badge>
|
||
))}
|
||
</Stack>
|
||
</Box>
|
||
{request.notes ? (
|
||
<Box>
|
||
<Text size="sm" c="dimmed" mb={4}>
|
||
Customer note
|
||
</Text>
|
||
<Text size="sm">{request.notes}</Text>
|
||
</Box>
|
||
) : null}
|
||
{request.reviewNote ? (
|
||
<Alert color="red" variant="light" radius="md" mt="sm">
|
||
Rejected: {request.reviewNote}
|
||
</Alert>
|
||
) : null}
|
||
</Stack>
|
||
</SectionCard>
|
||
</Stack>
|
||
|
||
<Modal
|
||
opened={rejectOpen}
|
||
onClose={() => setRejectOpen(false)}
|
||
centered
|
||
radius="md"
|
||
title="Reject shipment request"
|
||
>
|
||
<Stack gap="md">
|
||
<Textarea
|
||
label="Reason"
|
||
placeholder="Tell the customer why this request can't proceed…"
|
||
autosize
|
||
minRows={3}
|
||
value={rejectNote}
|
||
onChange={(e) => setRejectNote(e.currentTarget.value)}
|
||
/>
|
||
<Group justify="flex-end">
|
||
<Button variant="default" onClick={() => setRejectOpen(false)}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
color="red"
|
||
loading={reject.isPending}
|
||
onClick={() => reject.mutate()}
|
||
>
|
||
Reject request
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
</PageContainer>
|
||
);
|
||
}
|