add goverment booking

This commit is contained in:
Marshal
2026-07-31 01:25:47 +00:00
parent 7a72245229
commit d07a2e239e
2 changed files with 88 additions and 121 deletions

View File

@@ -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>

View File

@@ -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>