mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Updated ContractClearanceService and ContractsController to remove region parameter from queue method. - Enhanced ContractsRepository to attach contract files for download and added attachContractFiles method. - Modified ContractsService to persist cargo scope with quantity cap based on contract kind. - Introduced quantityCap field in CreateContractCargoScopeDto and ContractCargoScope entity. - Implemented capacity tracking in the frontend with ContractCapacityNotice component to display remaining bookable quantities. - Updated various components and services to support new capacity features, including hooks and API calls. - Added migration to include quantity_cap column in contract_cargo_scope table.
567 lines
19 KiB
TypeScript
567 lines
19 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Center,
|
|
Divider,
|
|
Grid,
|
|
Group,
|
|
Loader,
|
|
NumberInput,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
Textarea,
|
|
TextInput,
|
|
} from "@mantine/core";
|
|
import {
|
|
Container as ContainerIcon,
|
|
FileText,
|
|
Package,
|
|
Plus,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import type { Freight } from "@edr/types";
|
|
|
|
import { PageContainer } from "@/components/page";
|
|
import { PageHeader } from "@/components/page/PageHeader";
|
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
|
import {
|
|
useContractCapacity,
|
|
useContractDetail,
|
|
useContractMutations,
|
|
} from "@/hooks/contracts/useContracts";
|
|
import { Boxes } from "lucide-react";
|
|
|
|
interface UnitDraft {
|
|
containerNumber: string;
|
|
sealNumber: string;
|
|
vgmTons: number | string;
|
|
}
|
|
|
|
interface ContainerLineDraft {
|
|
containerSize: string;
|
|
hazardousQuantity: number | string;
|
|
reeferQuantity: number | string;
|
|
units: UnitDraft[];
|
|
}
|
|
|
|
interface BulkLineDraft {
|
|
cargoTypeId: string;
|
|
cargoWeightTons: number | string;
|
|
itemCount: number | string;
|
|
hazardousQuantity: number | string;
|
|
}
|
|
|
|
function emptyUnit(): UnitDraft {
|
|
return { containerNumber: "", sealNumber: "", vgmTons: "" };
|
|
}
|
|
|
|
export default function GlCreateBookingForm() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const { data: contract, isLoading } = useContractDetail(id);
|
|
const { data: capacity = [] } = useContractCapacity(id);
|
|
const mutations = useContractMutations(id ?? "");
|
|
|
|
const [scheduledDate, setScheduledDate] = useState("");
|
|
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
|
const [notes, setNotes] = useState("");
|
|
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
|
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
|
|
|
const isContainer = contract?.freightType === "CONTAINER";
|
|
const routes = useMemo(
|
|
() =>
|
|
[...(contract?.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder),
|
|
[contract?.routes],
|
|
);
|
|
const needsRouteSelect = contract?.contractKind === "GENERAL" && routes.length > 1;
|
|
|
|
const containerSizes = useMemo(() => {
|
|
const sizes = new Set<string>();
|
|
(contract?.cargoScope ?? []).forEach((s) => {
|
|
if (s.containerSize) sizes.add(s.containerSize);
|
|
});
|
|
return [...sizes];
|
|
}, [contract?.cargoScope]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<PageContainer>
|
|
<Center mih="50vh">
|
|
<Loader color="gray" />
|
|
</Center>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
if (!contract) {
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Contract not found"
|
|
backTo="/dashboard/contracts/clearance"
|
|
/>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
// ── Container line helpers ──
|
|
const addContainerLine = () =>
|
|
setContainerLines((prev) => [
|
|
...prev,
|
|
{
|
|
containerSize: containerSizes[0] ?? "20ft",
|
|
hazardousQuantity: "",
|
|
reeferQuantity: "",
|
|
units: [emptyUnit()],
|
|
},
|
|
]);
|
|
const removeContainerLine = (idx: number) =>
|
|
setContainerLines((prev) => prev.filter((_, i) => i !== idx));
|
|
const patchLine = (idx: number, patch: Partial<ContainerLineDraft>) =>
|
|
setContainerLines((prev) =>
|
|
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
|
|
);
|
|
const addUnit = (lineIdx: number) =>
|
|
patchLine(lineIdx, {
|
|
units: [...containerLines[lineIdx].units, emptyUnit()],
|
|
});
|
|
const removeUnit = (lineIdx: number, unitIdx: number) =>
|
|
patchLine(lineIdx, {
|
|
units: containerLines[lineIdx].units.filter((_, i) => i !== unitIdx),
|
|
});
|
|
const patchUnit = (
|
|
lineIdx: number,
|
|
unitIdx: number,
|
|
patch: Partial<UnitDraft>,
|
|
) =>
|
|
patchLine(lineIdx, {
|
|
units: containerLines[lineIdx].units.map((u, i) =>
|
|
i === unitIdx ? { ...u, ...patch } : u,
|
|
),
|
|
});
|
|
|
|
// ── Bulk line helpers ──
|
|
const addBulkLine = () =>
|
|
setBulkLines((prev) => [
|
|
...prev,
|
|
{ cargoTypeId: "", cargoWeightTons: "", itemCount: "", hazardousQuantity: "" },
|
|
]);
|
|
const removeBulkLine = (idx: number) =>
|
|
setBulkLines((prev) => prev.filter((_, i) => i !== idx));
|
|
const patchBulk = (idx: number, patch: Partial<BulkLineDraft>) =>
|
|
setBulkLines((prev) =>
|
|
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
|
|
);
|
|
|
|
const canSubmit =
|
|
Boolean(scheduledDate) &&
|
|
(!needsRouteSelect || Boolean(contractRouteId)) &&
|
|
(isContainer ? containerLines.length > 0 : bulkLines.length > 0);
|
|
|
|
const handleSubmit = () => {
|
|
if (!scheduledDate) return;
|
|
|
|
const payload: Freight.CreateBookingUnderContractDto = {
|
|
scheduledDate,
|
|
...(contractRouteId ? { contractRouteId } : {}),
|
|
...(notes.trim() ? { notes: notes.trim() } : {}),
|
|
};
|
|
|
|
if (isContainer) {
|
|
payload.containers = containerLines.map((l) => ({
|
|
containerSize: l.containerSize,
|
|
quantity: l.units.length,
|
|
...(l.hazardousQuantity !== ""
|
|
? { hazardousQuantity: Number(l.hazardousQuantity) }
|
|
: {}),
|
|
...(l.reeferQuantity !== ""
|
|
? { reeferQuantity: Number(l.reeferQuantity) }
|
|
: {}),
|
|
units: l.units.map((u) => ({
|
|
containerNumber: u.containerNumber,
|
|
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
|
vgmTons: Number(u.vgmTons) || 0,
|
|
})),
|
|
}));
|
|
} else {
|
|
payload.bulkLines = bulkLines.map((l) => ({
|
|
...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}),
|
|
...(l.cargoWeightTons !== ""
|
|
? { cargoWeightTons: Number(l.cargoWeightTons) }
|
|
: {}),
|
|
...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}),
|
|
...(l.hazardousQuantity !== ""
|
|
? { hazardousQuantity: Number(l.hazardousQuantity) }
|
|
: {}),
|
|
}));
|
|
}
|
|
|
|
mutations.createBooking.mutate(payload, {
|
|
onSuccess: (booking) =>
|
|
navigate(`/dashboard/bookings/${booking.id}/milestones`),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Create booking (GL)"
|
|
subtitle={`Enter the shipment details on behalf of the customer for contract ${contract.reference}.`}
|
|
backTo={`/dashboard/contracts/clearance/${contract.id}`}
|
|
breadcrumbs={[
|
|
{ label: "Contract Clearance", href: "/dashboard/contracts/clearance" },
|
|
{
|
|
label: contract.reference,
|
|
href: `/dashboard/contracts/clearance/${contract.id}`,
|
|
},
|
|
{ label: "Create booking" },
|
|
]}
|
|
/>
|
|
|
|
<Stack gap="lg">
|
|
{capacity.length > 0 && (
|
|
<Alert
|
|
color={capacity.every((c) => c.remaining === 0) ? "red" : "blue"}
|
|
variant="light"
|
|
radius="md"
|
|
icon={<Boxes size={16} />}
|
|
title="Contract draw-down capacity"
|
|
>
|
|
<Group gap={8} wrap="wrap">
|
|
{capacity.map((c, i) => (
|
|
<Badge
|
|
key={i}
|
|
color={c.remaining === 0 ? "red" : "blue"}
|
|
variant="light"
|
|
radius="sm"
|
|
>
|
|
{c.containerSize ?? "Bulk"}: {c.remaining} of {c.cap} left
|
|
</Badge>
|
|
))}
|
|
</Group>
|
|
</Alert>
|
|
)}
|
|
<SectionCard icon={FileText} title="Schedule">
|
|
<Grid gap="md">
|
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
|
<TextInput
|
|
label="Scheduled date"
|
|
type="date"
|
|
description="Binding shipment day"
|
|
value={scheduledDate}
|
|
onChange={(e) => setScheduledDate(e.currentTarget.value)}
|
|
required
|
|
/>
|
|
</Grid.Col>
|
|
{needsRouteSelect && (
|
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
|
<Select
|
|
label="Route"
|
|
placeholder="Select contract route"
|
|
value={contractRouteId}
|
|
onChange={setContractRouteId}
|
|
data={routes.map((r) => ({
|
|
value: r.id,
|
|
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"} → ${
|
|
r.destinationYard?.label ??
|
|
r.destinationYard?.code ??
|
|
"Destination"
|
|
}`,
|
|
}))}
|
|
required
|
|
/>
|
|
</Grid.Col>
|
|
)}
|
|
</Grid>
|
|
</SectionCard>
|
|
|
|
{isContainer ? (
|
|
<SectionCard
|
|
icon={ContainerIcon}
|
|
title="Containers"
|
|
extra={
|
|
<Button
|
|
size="compact-sm"
|
|
variant="light"
|
|
color="edr-green"
|
|
leftSection={<Plus size={14} />}
|
|
onClick={addContainerLine}
|
|
>
|
|
Add line
|
|
</Button>
|
|
}
|
|
>
|
|
{containerLines.length === 0 ? (
|
|
<Text size="sm" c="dimmed">
|
|
Add at least one container line.
|
|
</Text>
|
|
) : (
|
|
<Stack gap="lg">
|
|
{containerLines.map((line, lineIdx) => (
|
|
<Box
|
|
key={lineIdx}
|
|
p="md"
|
|
style={{
|
|
borderRadius: 10,
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
}}
|
|
>
|
|
<Group justify="space-between" mb="sm">
|
|
<Text fw={600} size="sm">
|
|
Line {lineIdx + 1}
|
|
</Text>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
onClick={() => removeContainerLine(lineIdx)}
|
|
aria-label="Remove line"
|
|
>
|
|
<Trash2 size={16} />
|
|
</ActionIcon>
|
|
</Group>
|
|
<Grid gap="sm">
|
|
<Grid.Col span={{ base: 12, sm: 4 }}>
|
|
<Select
|
|
label="Container size"
|
|
value={line.containerSize}
|
|
onChange={(v) =>
|
|
patchLine(lineIdx, {
|
|
containerSize: v ?? line.containerSize,
|
|
})
|
|
}
|
|
data={
|
|
containerSizes.length > 0
|
|
? containerSizes
|
|
: ["20ft", "40ft"]
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 6, sm: 4 }}>
|
|
<NumberInput
|
|
label="Hazard qty"
|
|
min={0}
|
|
value={line.hazardousQuantity}
|
|
onChange={(v) =>
|
|
patchLine(lineIdx, { hazardousQuantity: v })
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 6, sm: 4 }}>
|
|
<NumberInput
|
|
label="Reefer qty"
|
|
min={0}
|
|
value={line.reeferQuantity}
|
|
onChange={(v) =>
|
|
patchLine(lineIdx, { reeferQuantity: v })
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
</Grid>
|
|
|
|
<Divider
|
|
my="sm"
|
|
label={`${line.units.length} container unit${
|
|
line.units.length === 1 ? "" : "s"
|
|
}`}
|
|
labelPosition="left"
|
|
/>
|
|
|
|
<Stack gap="xs">
|
|
{line.units.map((unit, unitIdx) => (
|
|
<Grid key={unitIdx} gap="xs" align="flex-end">
|
|
<Grid.Col span={{ base: 12, sm: 4 }}>
|
|
<TextInput
|
|
label={unitIdx === 0 ? "Container no." : undefined}
|
|
placeholder="MSKU1234567"
|
|
value={unit.containerNumber}
|
|
onChange={(e) =>
|
|
patchUnit(lineIdx, unitIdx, {
|
|
containerNumber: e.currentTarget.value,
|
|
})
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 6, sm: 3 }}>
|
|
<TextInput
|
|
label={unitIdx === 0 ? "Seal no." : undefined}
|
|
placeholder="Optional"
|
|
value={unit.sealNumber}
|
|
onChange={(e) =>
|
|
patchUnit(lineIdx, unitIdx, {
|
|
sealNumber: e.currentTarget.value,
|
|
})
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 5, sm: 3 }}>
|
|
<NumberInput
|
|
label={unitIdx === 0 ? "VGM (t)" : undefined}
|
|
min={0}
|
|
decimalScale={2}
|
|
value={unit.vgmTons}
|
|
onChange={(v) =>
|
|
patchUnit(lineIdx, unitIdx, { vgmTons: v })
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 1, sm: 2 }}>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
disabled={line.units.length === 1}
|
|
onClick={() => removeUnit(lineIdx, unitIdx)}
|
|
aria-label="Remove unit"
|
|
>
|
|
<Trash2 size={15} />
|
|
</ActionIcon>
|
|
</Grid.Col>
|
|
</Grid>
|
|
))}
|
|
<Button
|
|
size="compact-xs"
|
|
variant="subtle"
|
|
color="edr-green"
|
|
leftSection={<Plus size={13} />}
|
|
onClick={() => addUnit(lineIdx)}
|
|
style={{ alignSelf: "flex-start" }}
|
|
>
|
|
Add container unit
|
|
</Button>
|
|
</Stack>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</SectionCard>
|
|
) : (
|
|
<SectionCard
|
|
icon={Package}
|
|
title="Bulk cargo"
|
|
extra={
|
|
<Button
|
|
size="compact-sm"
|
|
variant="light"
|
|
color="edr-green"
|
|
leftSection={<Plus size={14} />}
|
|
onClick={addBulkLine}
|
|
>
|
|
Add line
|
|
</Button>
|
|
}
|
|
>
|
|
{bulkLines.length === 0 ? (
|
|
<Text size="sm" c="dimmed">
|
|
Add at least one bulk line.
|
|
</Text>
|
|
) : (
|
|
<Stack gap="md">
|
|
{bulkLines.map((line, idx) => (
|
|
<Box
|
|
key={idx}
|
|
p="md"
|
|
style={{
|
|
borderRadius: 10,
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
}}
|
|
>
|
|
<Group justify="space-between" mb="sm">
|
|
<Text fw={600} size="sm">
|
|
Line {idx + 1}
|
|
</Text>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
onClick={() => removeBulkLine(idx)}
|
|
aria-label="Remove line"
|
|
>
|
|
<Trash2 size={16} />
|
|
</ActionIcon>
|
|
</Group>
|
|
<Grid gap="sm">
|
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
|
<TextInput
|
|
label="Cargo type id"
|
|
placeholder="Optional"
|
|
value={line.cargoTypeId}
|
|
onChange={(e) =>
|
|
patchBulk(idx, { cargoTypeId: e.currentTarget.value })
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 6, sm: 6 }}>
|
|
<NumberInput
|
|
label="Weight (tons)"
|
|
min={0}
|
|
decimalScale={2}
|
|
value={line.cargoWeightTons}
|
|
onChange={(v) =>
|
|
patchBulk(idx, { cargoWeightTons: v })
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 6, sm: 6 }}>
|
|
<NumberInput
|
|
label="Item count"
|
|
min={0}
|
|
value={line.itemCount}
|
|
onChange={(v) => patchBulk(idx, { itemCount: v })}
|
|
/>
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 6, sm: 6 }}>
|
|
<NumberInput
|
|
label="Hazard qty"
|
|
min={0}
|
|
value={line.hazardousQuantity}
|
|
onChange={(v) =>
|
|
patchBulk(idx, { hazardousQuantity: v })
|
|
}
|
|
/>
|
|
</Grid.Col>
|
|
</Grid>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</SectionCard>
|
|
)}
|
|
|
|
<SectionCard icon={FileText} title="Notes">
|
|
<Textarea
|
|
placeholder="Internal GL notes (optional)"
|
|
autosize
|
|
minRows={2}
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.currentTarget.value)}
|
|
/>
|
|
</SectionCard>
|
|
|
|
<Group justify="flex-end">
|
|
<Button
|
|
variant="default"
|
|
onClick={() =>
|
|
navigate(`/dashboard/contracts/clearance/${contract.id}`)
|
|
}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
disabled={!canSubmit}
|
|
loading={mutations.createBooking.isPending}
|
|
onClick={handleSubmit}
|
|
>
|
|
Create booking
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</PageContainer>
|
|
);
|
|
}
|