Merge pull request #1035 from Tria-plc/freight_feature/usermanagement

add goverment booking
This commit is contained in:
marshal
2026-07-31 04:26:31 +03:00
committed by GitHub
2 changed files with 88 additions and 121 deletions

View File

@@ -13,7 +13,6 @@ import {
SegmentedControl,
Select,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
@@ -84,6 +83,8 @@ interface ContainerLine {
containerTypeId: string | null;
quantity: number;
vgmPerUnitTons: number;
/** Physical container numbers, one per unit (index < quantity; blanks allowed). */
containerNumbers: string[];
}
let lineCounter = 0;
@@ -92,6 +93,7 @@ const newLine = (): ContainerLine => ({
containerTypeId: null,
quantity: 1,
vgmPerUnitTons: 20,
containerNumbers: [],
});
const fmtTons = (n: number) =>
@@ -165,9 +167,8 @@ export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isGovernment, setIsGovernment] = useState(false);
const [companyId, setCompanyId] = useState<string | null>(null);
// Government bookings bill to a real government company + an explicit profile.
// This page creates GOVERNMENT bookings only — commercial customers book
// through the portal. Bills to a government company + an explicit profile.
const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
const [govProfileId, setGovProfileId] = useState<string | null>(null);
// 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>,
});
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.
const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({
queryKey: ["companies", "government", "active"],
@@ -206,7 +197,6 @@ export default function NewBookingPage() {
kind: "government",
status: "active",
}),
enabled: isGovernment,
});
const govCompanies = govCompaniesPage?.items ?? [];
@@ -338,7 +328,7 @@ export default function NewBookingPage() {
!sameYard &&
Boolean(serviceTypeId) &&
departureSatisfied &&
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
Boolean(govCompanyId && govProfileId) &&
allLinesValid &&
!hasOdd20ft;
@@ -350,9 +340,9 @@ export default function NewBookingPage() {
const createMutation = useMutation({
mutationFn: () =>
bookingsService.create({
isGovernment,
companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
companyProfileId: isGovernment ? govProfileId || undefined : undefined,
isGovernment: true,
companyId: govCompanyId || undefined,
companyProfileId: govProfileId || undefined,
freightType: "CONTAINER",
contractType: "NEW",
equipmentReturn: "NA",
@@ -369,20 +359,19 @@ export default function NewBookingPage() {
containerTypeId: l.containerTypeId,
quantity: l.quantity,
vgmPerUnitTons: l.vgmPerUnitTons,
containerNumbers: l.containerNumbers
.map((n) => n.trim())
.filter(Boolean),
})),
}),
onSuccess: async (booking) => {
if (isGovernment) {
// The server already expedited + generated the contract at creation;
// this call is an idempotent no-op that doubles as a retry if that
// best-effort step failed.
await bookingsService.governmentExpedite(booking.id);
toast.success(
"Government booking created — contract generated, priority scheduling queued",
);
} else {
toast.success("Booking created as draft");
}
// The server already expedited + generated the contract at creation;
// this call is an idempotent no-op that doubles as a retry if that
// best-effort step failed.
await bookingsService.governmentExpedite(booking.id);
toast.success(
"Government booking created — contract generated, priority scheduling queued",
);
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
navigate(`/dashboard/booking-requests/${booking.id}`);
},
@@ -414,55 +403,38 @@ export default function NewBookingPage() {
{/* LEFT — form */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<FormSection icon={Layers} title="Booking type" subtitle="Who is booking and what kind of freight" accent="edr-green">
<Stack gap="md">
<Switch
label="Government booking"
description="Bills to a government entity + profile. Expedited to the scheduling queue."
checked={isGovernment}
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
<FormSection
icon={Layers}
title="Government booking"
subtitle="Bills to a government entity + profile — expedited past every customer step"
accent="edr-green"
>
<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 ? (
<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"
/>
<Select
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>
<Select
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>
</FormSection>
<FormSection icon={MapPin} title="Route & service" subtitle="Direction, origin, destination and service type" accent="blue">
@@ -644,6 +616,32 @@ export default function NewBookingPage() {
</ActionIcon>
</Tooltip>
</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>
);
})}
@@ -688,11 +686,9 @@ export default function NewBookingPage() {
<Badge variant="light" color={tradeDirection === "EXPORT" ? "orange" : "blue"} radius="sm">
{tradeDirection === "EXPORT" ? "Export" : "Import"}
</Badge>
{isGovernment ? (
<Badge variant="light" color="grape" radius="sm">
Government
</Badge>
) : null}
<Badge variant="light" color="grape" radius="sm">
Government
</Badge>
</Group>
<Stack gap={8}>
@@ -746,7 +742,7 @@ export default function NewBookingPage() {
onClick={() => createMutation.mutate()}
leftSection={<Ship size={18} />}
>
{isGovernment ? "Create & expedite" : "Create draft"}
Create & expedite
</Button>
<Button variant="default" fullWidth onClick={() => navigate("/dashboard/booking-requests")}>
Cancel
@@ -756,9 +752,9 @@ export default function NewBookingPage() {
<Group gap={6} mt="md" align="flex-start" wrap="nowrap">
<Info size={14} color="var(--mantine-color-gray-5)" style={{ marginTop: 2, flexShrink: 0 }} />
<Text size="xs" c="dimmed">
{isGovernment
? "Government bookings skip every customer step: paid & eligible immediately, contract generated automatically (signable any time), priority seat on any open train of the route."
: "Overweight container lines are allowed here and flagged later at scheduling."}
Government bookings skip every customer step: paid & eligible
immediately, contract + PDF generated automatically (signable
any time), priority seat on any open train of the route.
</Text>
</Group>
</Paper>

View File

@@ -196,7 +196,6 @@ export default function TrainScheduleV2DetailPage() {
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
const downloadMarshalling = useMutation({
mutationFn: ({ id, direction }: { id: string; direction?: string | null }) =>
@@ -383,7 +382,6 @@ export default function TrainScheduleV2DetailPage() {
: [];
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
const canDispatch = schedule.status === "SCHEDULED";
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
@@ -578,8 +576,8 @@ export default function TrainScheduleV2DetailPage() {
{
key: "finalize",
icon: CheckCircle2,
title: "Finalize",
subtitle: "Lock the plan & dispatch",
title: "Dispatch",
subtitle: "Review the consist & dispatch",
complete: finalizeComplete,
},
];
@@ -792,7 +790,7 @@ export default function TrainScheduleV2DetailPage() {
radius="md"
onClick={() => setActiveStep(finalizeStep)}
>
Skip to finalize
Skip to dispatch
</Button>
</Group>
) : null}
@@ -836,39 +834,12 @@ export default function TrainScheduleV2DetailPage() {
<Stack gap={2}>
<Text fw={600}>Ready to depart</Text>
<Text size="sm" c="dimmed">
Finalizing locks the plan and moves the schedule to{" "}
<Text span fw={600} c="edr-green.7">
SCHEDULED
</Text>
. Dispatch then begins rail movement and notifies the yard.
Dispatch begins rail movement and notifies the yard.
</Text>
</Stack>
</Group>
</Paper>
<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 ? (
<Button
color="edr-green"
@@ -881,7 +852,7 @@ export default function TrainScheduleV2DetailPage() {
Dispatch train
</Button>
) : null}
{!canFinalize && !canDispatch ? (
{!canDispatch ? (
<Text size="sm" c="dimmed">
No actions available for this schedule status.
</Text>