Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
Marshal 11c7f1bb74 add quantity cap for GENERAL contracts and implement capacity tracking
- 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.
2026-06-28 17:32:18 +00:00

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>
);
}