mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
contrat,booking,global logestic
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
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 {
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
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 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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user