mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
add goverment booking
This commit is contained in:
@@ -13,7 +13,6 @@ import {
|
|||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
@@ -84,6 +83,8 @@ interface ContainerLine {
|
|||||||
containerTypeId: string | null;
|
containerTypeId: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
vgmPerUnitTons: number;
|
vgmPerUnitTons: number;
|
||||||
|
/** Physical container numbers, one per unit (index < quantity; blanks allowed). */
|
||||||
|
containerNumbers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let lineCounter = 0;
|
let lineCounter = 0;
|
||||||
@@ -92,6 +93,7 @@ const newLine = (): ContainerLine => ({
|
|||||||
containerTypeId: null,
|
containerTypeId: null,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
vgmPerUnitTons: 20,
|
vgmPerUnitTons: 20,
|
||||||
|
containerNumbers: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const fmtTons = (n: number) =>
|
const fmtTons = (n: number) =>
|
||||||
@@ -165,9 +167,8 @@ export default function NewBookingPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [isGovernment, setIsGovernment] = useState(false);
|
// This page creates GOVERNMENT bookings only — commercial customers book
|
||||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
// through the portal. Bills to a government company + an explicit profile.
|
||||||
// Government bookings bill to a real government company + an explicit profile.
|
|
||||||
const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
|
const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
|
||||||
const [govProfileId, setGovProfileId] = useState<string | null>(null);
|
const [govProfileId, setGovProfileId] = useState<string | null>(null);
|
||||||
// Container freight only for now — bulk is disabled on this page.
|
// Container freight only for now — bulk is disabled on this page.
|
||||||
@@ -186,16 +187,6 @@ export default function NewBookingPage() {
|
|||||||
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
|
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: companiesPage, isLoading: companiesLoading } = useQuery({
|
|
||||||
queryKey: ["companies", "list"],
|
|
||||||
queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const companyOptions = (companiesPage?.items ?? []).map((c) => ({
|
|
||||||
value: c.id,
|
|
||||||
label: c.name || c.email || c.tin || c.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Active government companies (kind=government) the booking can bill to.
|
// Active government companies (kind=government) the booking can bill to.
|
||||||
const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({
|
const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({
|
||||||
queryKey: ["companies", "government", "active"],
|
queryKey: ["companies", "government", "active"],
|
||||||
@@ -206,7 +197,6 @@ export default function NewBookingPage() {
|
|||||||
kind: "government",
|
kind: "government",
|
||||||
status: "active",
|
status: "active",
|
||||||
}),
|
}),
|
||||||
enabled: isGovernment,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const govCompanies = govCompaniesPage?.items ?? [];
|
const govCompanies = govCompaniesPage?.items ?? [];
|
||||||
@@ -338,7 +328,7 @@ export default function NewBookingPage() {
|
|||||||
!sameYard &&
|
!sameYard &&
|
||||||
Boolean(serviceTypeId) &&
|
Boolean(serviceTypeId) &&
|
||||||
departureSatisfied &&
|
departureSatisfied &&
|
||||||
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
|
Boolean(govCompanyId && govProfileId) &&
|
||||||
allLinesValid &&
|
allLinesValid &&
|
||||||
!hasOdd20ft;
|
!hasOdd20ft;
|
||||||
|
|
||||||
@@ -350,9 +340,9 @@ export default function NewBookingPage() {
|
|||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
bookingsService.create({
|
bookingsService.create({
|
||||||
isGovernment,
|
isGovernment: true,
|
||||||
companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
|
companyId: govCompanyId || undefined,
|
||||||
companyProfileId: isGovernment ? govProfileId || undefined : undefined,
|
companyProfileId: govProfileId || undefined,
|
||||||
freightType: "CONTAINER",
|
freightType: "CONTAINER",
|
||||||
contractType: "NEW",
|
contractType: "NEW",
|
||||||
equipmentReturn: "NA",
|
equipmentReturn: "NA",
|
||||||
@@ -369,20 +359,19 @@ export default function NewBookingPage() {
|
|||||||
containerTypeId: l.containerTypeId,
|
containerTypeId: l.containerTypeId,
|
||||||
quantity: l.quantity,
|
quantity: l.quantity,
|
||||||
vgmPerUnitTons: l.vgmPerUnitTons,
|
vgmPerUnitTons: l.vgmPerUnitTons,
|
||||||
|
containerNumbers: l.containerNumbers
|
||||||
|
.map((n) => n.trim())
|
||||||
|
.filter(Boolean),
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
onSuccess: async (booking) => {
|
onSuccess: async (booking) => {
|
||||||
if (isGovernment) {
|
// The server already expedited + generated the contract at creation;
|
||||||
// The server already expedited + generated the contract at creation;
|
// this call is an idempotent no-op that doubles as a retry if that
|
||||||
// this call is an idempotent no-op that doubles as a retry if that
|
// best-effort step failed.
|
||||||
// best-effort step failed.
|
await bookingsService.governmentExpedite(booking.id);
|
||||||
await bookingsService.governmentExpedite(booking.id);
|
toast.success(
|
||||||
toast.success(
|
"Government booking created — contract generated, priority scheduling queued",
|
||||||
"Government booking created — contract generated, priority scheduling queued",
|
);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.success("Booking created as draft");
|
|
||||||
}
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
|
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
|
||||||
navigate(`/dashboard/booking-requests/${booking.id}`);
|
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||||
},
|
},
|
||||||
@@ -414,55 +403,38 @@ export default function NewBookingPage() {
|
|||||||
{/* LEFT — form */}
|
{/* LEFT — form */}
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<FormSection icon={Layers} title="Booking type" subtitle="Who is booking and what kind of freight" accent="edr-green">
|
<FormSection
|
||||||
<Stack gap="md">
|
icon={Layers}
|
||||||
<Switch
|
title="Government booking"
|
||||||
label="Government booking"
|
subtitle="Bills to a government entity + profile — expedited past every customer step"
|
||||||
description="Bills to a government entity + profile. Expedited to the scheduling queue."
|
accent="edr-green"
|
||||||
checked={isGovernment}
|
>
|
||||||
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
|
<Group grow align="flex-start">
|
||||||
|
<Select
|
||||||
|
label="Government entity"
|
||||||
|
placeholder="Select government company"
|
||||||
|
data={govCompanyOptions}
|
||||||
|
value={govCompanyId}
|
||||||
|
onChange={setGovCompanyId}
|
||||||
|
searchable
|
||||||
|
required
|
||||||
|
disabled={govCompaniesLoading}
|
||||||
|
nothingFoundMessage="No active government companies"
|
||||||
/>
|
/>
|
||||||
{isGovernment ? (
|
<Select
|
||||||
<Group grow align="flex-start">
|
label="Profile"
|
||||||
<Select
|
placeholder={
|
||||||
label="Government entity"
|
govCompanyId ? "Select import/export profile" : "Pick an entity first"
|
||||||
placeholder="Select government company"
|
}
|
||||||
data={govCompanyOptions}
|
data={govProfileOptions}
|
||||||
value={govCompanyId}
|
value={govProfileId}
|
||||||
onChange={setGovCompanyId}
|
onChange={setGovProfileId}
|
||||||
searchable
|
searchable
|
||||||
required
|
required
|
||||||
disabled={govCompaniesLoading}
|
disabled={!govCompanyId}
|
||||||
nothingFoundMessage="No active government companies"
|
nothingFoundMessage="No active profiles for this entity"
|
||||||
/>
|
/>
|
||||||
<Select
|
</Group>
|
||||||
label="Profile"
|
|
||||||
placeholder={
|
|
||||||
govCompanyId ? "Select import/export profile" : "Pick an entity first"
|
|
||||||
}
|
|
||||||
data={govProfileOptions}
|
|
||||||
value={govProfileId}
|
|
||||||
onChange={setGovProfileId}
|
|
||||||
searchable
|
|
||||||
required
|
|
||||||
disabled={!govCompanyId}
|
|
||||||
nothingFoundMessage="No active profiles for this entity"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
label="Customer"
|
|
||||||
placeholder="Select company"
|
|
||||||
data={companyOptions}
|
|
||||||
value={companyId}
|
|
||||||
onChange={setCompanyId}
|
|
||||||
searchable
|
|
||||||
required
|
|
||||||
disabled={companiesLoading}
|
|
||||||
nothingFoundMessage="No companies found"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
<FormSection icon={MapPin} title="Route & service" subtitle="Direction, origin, destination and service type" accent="blue">
|
<FormSection icon={MapPin} title="Route & service" subtitle="Direction, origin, destination and service type" accent="blue">
|
||||||
@@ -644,6 +616,32 @@ export default function NewBookingPage() {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* One container number per unit (optional). */}
|
||||||
|
<Box mt="sm">
|
||||||
|
<Text size="xs" fw={500} c="dimmed" mb={4}>
|
||||||
|
Container numbers
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{Array.from(
|
||||||
|
{ length: Math.max(1, line.quantity || 1) },
|
||||||
|
(_, i) => (
|
||||||
|
<TextInput
|
||||||
|
key={i}
|
||||||
|
size="xs"
|
||||||
|
placeholder={`Container ${i + 1} — e.g. MSKU1234567`}
|
||||||
|
value={line.containerNumbers[i] ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...line.containerNumbers];
|
||||||
|
next[i] = e.currentTarget.value.toUpperCase();
|
||||||
|
updateLine(line.key, { containerNumbers: next });
|
||||||
|
}}
|
||||||
|
style={{ width: 210 }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -688,11 +686,9 @@ export default function NewBookingPage() {
|
|||||||
<Badge variant="light" color={tradeDirection === "EXPORT" ? "orange" : "blue"} radius="sm">
|
<Badge variant="light" color={tradeDirection === "EXPORT" ? "orange" : "blue"} radius="sm">
|
||||||
{tradeDirection === "EXPORT" ? "Export" : "Import"}
|
{tradeDirection === "EXPORT" ? "Export" : "Import"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isGovernment ? (
|
<Badge variant="light" color="grape" radius="sm">
|
||||||
<Badge variant="light" color="grape" radius="sm">
|
Government
|
||||||
Government
|
</Badge>
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack gap={8}>
|
<Stack gap={8}>
|
||||||
@@ -746,7 +742,7 @@ export default function NewBookingPage() {
|
|||||||
onClick={() => createMutation.mutate()}
|
onClick={() => createMutation.mutate()}
|
||||||
leftSection={<Ship size={18} />}
|
leftSection={<Ship size={18} />}
|
||||||
>
|
>
|
||||||
{isGovernment ? "Create & expedite" : "Create draft"}
|
Create & expedite
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="default" fullWidth onClick={() => navigate("/dashboard/booking-requests")}>
|
<Button variant="default" fullWidth onClick={() => navigate("/dashboard/booking-requests")}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -756,9 +752,9 @@ export default function NewBookingPage() {
|
|||||||
<Group gap={6} mt="md" align="flex-start" wrap="nowrap">
|
<Group gap={6} mt="md" align="flex-start" wrap="nowrap">
|
||||||
<Info size={14} color="var(--mantine-color-gray-5)" style={{ marginTop: 2, flexShrink: 0 }} />
|
<Info size={14} color="var(--mantine-color-gray-5)" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{isGovernment
|
Government bookings skip every customer step: paid & eligible
|
||||||
? "Government bookings skip every customer step: paid & eligible immediately, contract generated automatically (signable any time), priority seat on any open train of the route."
|
immediately, contract + PDF generated automatically (signable
|
||||||
: "Overweight container lines are allowed here and flagged later at scheduling."}
|
any time), priority seat on any open train of the route.
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
|
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
|
||||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||||
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
||||||
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
|
|
||||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||||
const downloadMarshalling = useMutation({
|
const downloadMarshalling = useMutation({
|
||||||
mutationFn: ({ id, direction }: { id: string; direction?: string | null }) =>
|
mutationFn: ({ id, direction }: { id: string; direction?: string | null }) =>
|
||||||
@@ -383,7 +382,6 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
|
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
|
||||||
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
|
|
||||||
const canDispatch = schedule.status === "SCHEDULED";
|
const canDispatch = schedule.status === "SCHEDULED";
|
||||||
|
|
||||||
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
|
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
|
||||||
@@ -578,8 +576,8 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
{
|
{
|
||||||
key: "finalize",
|
key: "finalize",
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
title: "Finalize",
|
title: "Dispatch",
|
||||||
subtitle: "Lock the plan & dispatch",
|
subtitle: "Review the consist & dispatch",
|
||||||
complete: finalizeComplete,
|
complete: finalizeComplete,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -792,7 +790,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
onClick={() => setActiveStep(finalizeStep)}
|
onClick={() => setActiveStep(finalizeStep)}
|
||||||
>
|
>
|
||||||
Skip to finalize
|
Skip to dispatch
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -836,39 +834,12 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Text fw={600}>Ready to depart</Text>
|
<Text fw={600}>Ready to depart</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Finalizing locks the plan and moves the schedule to{" "}
|
Dispatch begins rail movement and notifies the yard.
|
||||||
<Text span fw={600} c="edr-green.7">
|
|
||||||
SCHEDULED
|
|
||||||
</Text>
|
|
||||||
. Dispatch then begins rail movement and notifies the yard.
|
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
<Group>
|
<Group>
|
||||||
{canFinalize ? (
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
size="md"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<CheckCircle2 size={18} />}
|
|
||||||
loading={finalize.isPending}
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await finalize.mutateAsync(scheduleId);
|
|
||||||
toast({ title: "Schedule finalized" });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: "Finalize failed",
|
|
||||||
description: parseError(err, "Could not finalize"),
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Finalize schedule
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDispatch ? (
|
{canDispatch ? (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -881,7 +852,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
Dispatch train
|
Dispatch train
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{!canFinalize && !canDispatch ? (
|
{!canDispatch ? (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No actions available for this schedule status.
|
No actions available for this schedule status.
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
Reference in New Issue
Block a user