Merge branch 'dev' into eims-bulk-register

This commit is contained in:
Hagernesh Tadesse
2026-08-21 16:03:03 +03:00
committed by GitHub
6 changed files with 111 additions and 53 deletions

View File

@@ -43,9 +43,10 @@ import { BookingClearanceChargeService } from './booking-clearance-charge.servic
import { BookingPayablesService } from './booking-payables.service'; import { BookingPayablesService } from './booking-payables.service';
import { ClearanceEventService } from './clearance-event.service'; import { ClearanceEventService } from './clearance-event.service';
import { import {
BillClearanceChargeDto,
RejectClearanceChargeDto, RejectClearanceChargeDto,
} from './dto/clearance-charge.dto'; } from './dto/clearance-charge.dto';
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
import { AdditionalChargeService } from './additional-charge.service'; import { AdditionalChargeService } from './additional-charge.service';
import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto'; import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto';
import { BookingContractService } from './booking-contract.service'; import { BookingContractService } from './booking-contract.service';

View File

@@ -18,6 +18,8 @@ interface TrainConsistViewProps {
scheduleDetail: TrainScheduleDetail; scheduleDetail: TrainScheduleDetail;
scheduleId: string; scheduleId: string;
maxWagons: number; maxWagons: number;
/** Hide the consist-wide Wagons stat tile (dispatch shows leg capacity instead). */
showWagonStat?: boolean;
/** Booking id selected in the side panel — highlights its wagons in the consist. */ /** Booking id selected in the side panel — highlights its wagons in the consist. */
highlightBookingId?: string | null; highlightBookingId?: string | null;
} }
@@ -45,6 +47,7 @@ export const TrainConsistView = ({
scheduleDetail, scheduleDetail,
scheduleId, scheduleId,
maxWagons, maxWagons,
showWagonStat = true,
highlightBookingId, highlightBookingId,
}: TrainConsistViewProps) => { }: TrainConsistViewProps) => {
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null); const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
@@ -172,6 +175,7 @@ export const TrainConsistView = ({
lengthMax={lengthMax} lengthMax={lengthMax}
wagonCount={wagonsUsed} wagonCount={wagonsUsed}
wagonMax={maxWagons} wagonMax={maxWagons}
showWagons={showWagonStat}
/> />
{/* Consist panel */} {/* Consist panel */}

View File

@@ -9,6 +9,12 @@ interface TrainStatsBarProps {
lengthMax: number | null; lengthMax: number | null;
wagonCount: number; wagonCount: number;
wagonMax: number; wagonMax: number;
/**
* The wagon tile is a consist-wide count, which reads as wrong on a
* multi-leg schedule where per-leg capacity is the real number. Dispatch
* hides it (leg capacity is shown there instead); the batch board keeps it.
*/
showWagons?: boolean;
} }
function pctColor(pct: number) { function pctColor(pct: number) {
@@ -83,6 +89,7 @@ export const TrainStatsBar = ({
lengthMax, lengthMax,
wagonCount, wagonCount,
wagonMax, wagonMax,
showWagons = true,
}: TrainStatsBarProps) => { }: TrainStatsBarProps) => {
const weightPct = weightMax ? (weightUsed / weightMax) * 100 : null; const weightPct = weightMax ? (weightUsed / weightMax) * 100 : null;
const lengthPct = lengthMax ? (lengthUsed / lengthMax) * 100 : null; const lengthPct = lengthMax ? (lengthUsed / lengthMax) * 100 : null;
@@ -95,7 +102,7 @@ export const TrainStatsBar = ({
withBorder withBorder
style={{ borderColor: "var(--mantine-color-gray-2)", background: "white" }} style={{ borderColor: "var(--mantine-color-gray-2)", background: "white" }}
> >
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg"> <SimpleGrid cols={{ base: 1, xs: showWagons ? 3 : 2 }} spacing="lg">
<StatTile <StatTile
icon={<Weight size={15} />} icon={<Weight size={15} />}
label="Gross weight" label="Gross weight"
@@ -108,7 +115,7 @@ export const TrainStatsBar = ({
px={{ base: 0, xs: "lg" }} px={{ base: 0, xs: "lg" }}
style={{ style={{
borderLeft: "1px solid var(--mantine-color-gray-2)", borderLeft: "1px solid var(--mantine-color-gray-2)",
borderRight: "1px solid var(--mantine-color-gray-2)", borderRight: showWagons ? "1px solid var(--mantine-color-gray-2)" : undefined,
}} }}
> >
<StatTile <StatTile
@@ -120,14 +127,16 @@ export const TrainStatsBar = ({
unit="m" unit="m"
/> />
</Box> </Box>
<StatTile {showWagons ? (
icon={<Train size={15} />} <StatTile
label="Wagons" icon={<Train size={15} />}
pct={wagonPct} label="Wagons"
current={String(wagonCount)} pct={wagonPct}
max={String(wagonMax)} current={String(wagonCount)}
unit="" max={String(wagonMax)}
/> unit=""
/>
) : null}
</SimpleGrid> </SimpleGrid>
</Paper> </Paper>
); );

View File

@@ -848,6 +848,7 @@ export default function TrainScheduleV2DetailPage() {
scheduleDetail={schedule} scheduleDetail={schedule}
scheduleId={scheduleId ?? ""} scheduleId={scheduleId ?? ""}
maxWagons={schedule.maxWagons ?? 53} maxWagons={schedule.maxWagons ?? 53}
showWagonStat={false}
/> />
) : ( ) : (
<TrainCompositionDiagram <TrainCompositionDiagram

View File

@@ -330,17 +330,6 @@ export default function TrainScheduleV2ListPage() {
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => ( cell: ({ row }) => (
<Stack gap={6}> <Stack gap={6}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
{row.original.direction ? (
<Badge size="xs" variant="light" color={directionColor(row.original.direction)}>
{row.original.direction}
</Badge>
) : null}
<ShippingLineBadge schedule={row.original} />
</Group>
<Box maw={260}> <Box maw={260}>
<RouteCorridor <RouteCorridor
origin={row.original.origin} origin={row.original.origin}
@@ -349,6 +338,14 @@ export default function TrainScheduleV2ListPage() {
orientation="vertical" orientation="vertical"
/> />
</Box> </Box>
<Group gap={6} wrap="nowrap">
{row.original.direction ? (
<Badge size="xs" variant="light" color={directionColor(row.original.direction)}>
{row.original.direction}
</Badge>
) : null}
<ShippingLineBadge schedule={row.original} />
</Group>
</Stack> </Stack>
), ),
}, },
@@ -888,12 +885,12 @@ export default function TrainScheduleV2ListPage() {
} }
/** /**
* The row's wagon chips, matching the detail page's wagon plan: used is slots * The row's wagon chips: used is slots carrying a booking allocation, the
* carrying a booking allocation, the denominator is the schedule's capacity * denominator is the schedule's capacity (API-computed: the larger of coupled
* (API-computed: the larger of coupled consist and planned `maxWagons`, since * consist and planned `maxWagons`, since wagons are coupled on demand). Both
* wagons are coupled on demand), and remaining excludes wagons reserved by * are consist-wide totals, so on a multi-leg schedule they do not describe any
* bookings that have not paid yet — that space is claimed, so it is not * single leg — the bookable/planned counts were dropped for that reason; the
* bookable. * detail page's wagon plan is the per-leg source of truth.
*/ */
/** Green tint for departures dedicated to a shipping line (overrides direction tint). */ /** Green tint for departures dedicated to a shipping line (overrides direction tint). */
const SHIPPING_LINE_ROW_STYLE = { const SHIPPING_LINE_ROW_STYLE = {
@@ -965,20 +962,15 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
const total = schedule.wagonsTotal ?? schedule.wagonCount; const total = schedule.wagonsTotal ?? schedule.wagonCount;
const used = schedule.wagonsUsed; const used = schedule.wagonsUsed;
const reserved = schedule.wagonsReserved ?? 0; const reserved = schedule.wagonsReserved ?? 0;
const remaining = schedule.wagonsRemaining;
if (used == null) { if (used == null || schedule.wagonCount === 0) {
return <MetricChip value={total} label="wgn" subtle />; return <MetricChip value={total} label="wgn" subtle />;
} }
return ( return (
<> <>
<MetricChip <MetricChip value={`${used}/${total}`} label="wgn used" />
value={`${used}/${total}`}
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
/>
{reserved > used ? <MetricChip value={reserved} label="reserved" subtle /> : null} {reserved > used ? <MetricChip value={reserved} label="reserved" subtle /> : null}
{remaining != null ? <MetricChip value={remaining} label="bookable" subtle /> : null}
</> </>
); );
} }
@@ -1049,9 +1041,6 @@ function ScheduleCard({
{schedule.reference} {schedule.reference}
</Text> </Text>
) : null} ) : null}
<Text fw={600} size="sm" lineClamp={1}>
{schedule.routeName ?? "Train schedule"}
</Text>
</Group> </Group>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{day} · {time} {day} · {time}

View File

@@ -8,11 +8,12 @@ import {
Package, Package,
TrainFront, TrainFront,
Truck, Truck,
Wallet,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
@@ -44,17 +45,17 @@ import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentMethodModal } from "./components/PaymentMethodModal"; import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { ScheduleCard } from "./components/ScheduleCard"; import { ScheduleCard } from "./components/ScheduleCard";
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection"; import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
import { PaymentsDueStrip, PaymentsTab } from "./components/PaymentsTab";
import { WarehouseLocationCard } from "./components/WarehouseLocationCard"; import { WarehouseLocationCard } from "./components/WarehouseLocationCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard"; import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
import { StatusHero } from "./components/StatusHero"; import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard"; import { SupportCard } from "./components/SupportCard";
import { WagonCancellationCard } from "./components/WagonCancellationCard";
import { WagonsTab } from "./components/WagonsTab"; import { WagonsTab } from "./components/WagonsTab";
import { fmtDate, isNegative, priceTotal } from "./utils"; import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash"; import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment"; import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment"; import { useBookingPayables } from "@/pages/bookings/payments/useBookingPayables";
// Pre-payment statuses the customer may self-cancel from this view (free of // Pre-payment statuses the customer may self-cancel from this view (free of
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can // charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
@@ -87,6 +88,22 @@ export function ReadonlyBookingView({
const navigate = useNavigate(); const navigate = useNavigate();
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice. // Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
useScrollToHash(); useScrollToHash();
// Active tab lives in the URL (?tab=payments) so list/home "Pay" buttons and
// notifications can land straight on the Payments tab.
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get("tab") ?? "overview";
const setTab = (next: string | null) =>
setSearchParams(
(prev) => {
if (!next || next === "overview") prev.delete("tab");
else prev.set("tab", next);
return prev;
},
{ replace: true },
);
// Everything the customer owes or must decide on — drives the header "Pay"
// button, the tab badge and the overview strip.
const payables = useBookingPayables(booking);
const status = booking.status as string; const status = booking.status as string;
const { viewer } = useFileViewer(); const { viewer } = useFileViewer();
@@ -187,17 +204,19 @@ export function ReadonlyBookingView({
<PageHeader <PageHeader
booking={booking} booking={booking}
actions={ actions={
(canApproveDelivery || (canPay && !showCountdown) || canCancel) && ( (canApproveDelivery ||
(payables.items.length > 0 && tab !== "payments") ||
canCancel) && (
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap">
{canApproveDelivery && ( {canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} /> <ApproveDeliveryButton bookingId={booking.id} />
)} )}
{canPay && !showCountdown && !isUsdOfflineBooking(booking) && ( {payables.items.length > 0 && tab !== "payments" && (
<HeaderButton <HeaderButton
green green
icon={<CreditCard size={16} />} icon={<CreditCard size={16} />}
label="Pay now" label="Pay"
onClick={pay.open} onClick={() => setTab("payments")}
/> />
)} )}
{canCancel && ( {canCancel && (
@@ -260,7 +279,8 @@ export function ReadonlyBookingView({
<KeyFactsStrip booking={booking} /> <KeyFactsStrip booking={booking} />
<Tabs <Tabs
defaultValue="overview" value={tab}
onChange={setTab}
keepMounted={false} keepMounted={false}
color="edr-green" color="edr-green"
styles={{ styles={{
@@ -277,6 +297,32 @@ export function ReadonlyBookingView({
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}> <Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
Overview Overview
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab
value="payments"
leftSection={<Wallet size={15} />}
rightSection={
payables.items.length > 0 ? (
<Text
component="span"
fz={11}
fw={800}
c="white"
px={6}
style={{
borderRadius: 999,
background: "#B45309",
lineHeight: "18px",
minWidth: 18,
textAlign: "center",
}}
>
{payables.items.length}
</Text>
) : undefined
}
>
Payments
</Tabs.Tab>
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}> <Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
Cargo Cargo
</Tabs.Tab> </Tabs.Tab>
@@ -298,12 +344,16 @@ export function ReadonlyBookingView({
<Tabs.Panel value="overview"> <Tabs.Panel value="overview">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* The only payment surface on Overview — everything payable lives
on the Payments tab. Hidden when nothing is outstanding. */}
<PaymentsDueStrip booking={booking} onOpen={() => setTab("payments")} />
<ContractCard booking={booking} /> <ContractCard booking={booking} />
{isClearance && <ClearanceCard booking={booking} />} {isClearance && <ClearanceCard booking={booking} />}
{/* Customs (Path B) shipments: GL's phased progress, the duty / {/* Customs (Path B) shipments: GL's phased progress, draft
additional-duty payments and the final invoice — all per booking. */} declaration and documents. Its payments moved to the Payments tab. */}
<BookingClearanceWorkflowBanner booking={booking} /> <BookingClearanceWorkflowBanner booking={booking} />
<BodyGrid <BodyGrid
@@ -333,11 +383,6 @@ export function ReadonlyBookingView({
title="Consignment & Schedule" title="Consignment & Schedule"
consignment consignment
/> />
{/* Renders only on PAID + paid + contract-backed bookings. */}
<WagonCancellationCard
booking={booking}
onBookingUpdated={onBookingUpdated}
/>
<CompanyInfoCard booking={booking} /> <CompanyInfoCard booking={booking} />
<SupportCard /> <SupportCard />
</> </>
@@ -346,6 +391,15 @@ export function ReadonlyBookingView({
</div> </div>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="payments">
<PaymentsTab
booking={booking}
pay={pay}
showCountdown={showCountdown}
onBookingUpdated={onBookingUpdated}
/>
</Tabs.Panel>
<Tabs.Panel value="cargo"> <Tabs.Panel value="cargo">
<CargoTab booking={booking} /> <CargoTab booking={booking} />
</Tabs.Panel> </Tabs.Panel>