mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1035 from Tria-plc/freight_feature/usermanagement
add goverment booking
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user