mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +00:00
Merge branch 'contrat-backup2' of github.com:Tria-plc/edr-platform into contrat-backup2
This commit is contained in:
@@ -145,6 +145,7 @@ export const URL_CONSTANTS = {
|
||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
||||
AVAILABLE_DAYS_FOR_CARGO: "/api/train-scheduling/available-days-for-cargo",
|
||||
MY_BOOKING_WINDOWS: "/api/train-scheduling/my-booking-windows",
|
||||
},
|
||||
|
||||
PAYMENTS: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RecentContractsSection,
|
||||
ShipmentsSection,
|
||||
StatsSection,
|
||||
UpcomingWindowsSection,
|
||||
} from "./components";
|
||||
import { useMyPortalData } from "./hooks";
|
||||
|
||||
@@ -37,6 +38,8 @@ export default function MyPortalPage() {
|
||||
dashboard,
|
||||
volumePoints,
|
||||
maxVolume,
|
||||
bookingWindowsQuery,
|
||||
bookingWindows,
|
||||
} = useMyPortalData(selectedProfileId ?? undefined);
|
||||
|
||||
const serviceOptions = companyProfiles.map((p) => ({
|
||||
@@ -95,6 +98,13 @@ export default function MyPortalPage() {
|
||||
contracts={allContracts}
|
||||
/> */}
|
||||
|
||||
{/* Upcoming/open booking windows on the customer's contract lanes —
|
||||
hidden when there is nothing coming up. */}
|
||||
<UpcomingWindowsSection
|
||||
windows={bookingWindows}
|
||||
isLoading={bookingWindowsQuery.isPending}
|
||||
/>
|
||||
|
||||
{/* Contracts + shipments side by side — the two primary tables. */}
|
||||
<Grid align="stretch">
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Box, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, CalendarClock } from "lucide-react";
|
||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||
import { Card } from "./Card";
|
||||
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
/** All window times are communicated in East Africa Time. */
|
||||
const TZ = "Africa/Addis_Ababa";
|
||||
|
||||
function fmtDay(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: TZ,
|
||||
});
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: TZ,
|
||||
});
|
||||
}
|
||||
|
||||
/** "Thu, 10 Jul · 08:00 – 11:00 EAT" (or a phase label when times are unset). */
|
||||
function windowLabel(w: MyBookingWindow): string {
|
||||
if (w.windowOpensAt && w.windowClosesAt) {
|
||||
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime(
|
||||
w.windowClosesAt,
|
||||
)} EAT`;
|
||||
}
|
||||
if (w.windowOpensAt) {
|
||||
return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`;
|
||||
}
|
||||
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function Pill({
|
||||
children,
|
||||
bg,
|
||||
color,
|
||||
border,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
bg: string;
|
||||
color: string;
|
||||
border?: string;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
borderRadius: 999,
|
||||
padding: "4px 10px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
backgroundColor: bg,
|
||||
color,
|
||||
border: border ? `1px solid ${border}` : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionBadge({ direction }: { direction: MyBookingWindow["direction"] }) {
|
||||
if (!direction) return null;
|
||||
const isImport = direction === "IMPORT";
|
||||
return (
|
||||
<Pill
|
||||
bg={isImport ? "#EAF1FB" : "#ECF6F1"}
|
||||
color={isImport ? "#2E5B96" : "#0A6F4D"}
|
||||
>
|
||||
{isImport ? "Import" : "Export"}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ window: w }: { window: MyBookingWindow }) {
|
||||
if (w.isOpenNow) {
|
||||
return (
|
||||
<Pill bg="#ECF6F1" color="#0A6F4D" border="#CDEBDD">
|
||||
Open now
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
if (w.windowPhase === "PRE_WINDOW" && w.windowOpensAt) {
|
||||
return (
|
||||
<Pill bg="#FEF6E6" color="#B07D14">
|
||||
Opens at {fmtTime(w.windowOpensAt)} EAT
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Pill bg="#F1F5F9" color={MUTED}>
|
||||
Upcoming
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
interface UpcomingWindowsSectionProps {
|
||||
windows: MyBookingWindow[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's upcoming/open booking windows on their active-contract
|
||||
* lanes. Import trains open a window on one booking day; export trains open
|
||||
* 24h before departure. Hidden entirely when there is nothing to show.
|
||||
*/
|
||||
export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||
windows,
|
||||
isLoading,
|
||||
}: UpcomingWindowsSectionProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Nothing upcoming — keep the dashboard uncluttered.
|
||||
if (!isLoading && windows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card padding={28}>
|
||||
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
|
||||
<Box>
|
||||
<Text fz={19} fw={800} c="edr-text">
|
||||
Booking Windows
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted">
|
||||
Upcoming and open booking windows on your contract lanes
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack gap={6}>
|
||||
{[1, 2].map((i) => (
|
||||
<Skeleton key={i} height={60} radius="md" />
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={10}>
|
||||
{windows.map((w) => (
|
||||
<Group
|
||||
key={`${w.scheduleId}-${w.bookingCycleNo}`}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap={12}
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${w.isOpenNow ? "#CDEBDD" : BORDER}`,
|
||||
backgroundColor: w.isOpenNow ? "#F4FBF7" : undefined,
|
||||
cursor: w.isOpenNow ? "pointer" : "default",
|
||||
}}
|
||||
onClick={
|
||||
w.isOpenNow ? () => navigate("/contracts") : undefined
|
||||
}
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{w.origin ?? "—"}
|
||||
</Text>
|
||||
<ArrowRight size={13} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{w.destination ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={5} wrap="nowrap" mt={2}>
|
||||
<CalendarClock size={12} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
<Text fz={12} style={{ color: MUTED }} truncate>
|
||||
{windowLabel(w)} · Departs {fmtDay(w.departureDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<DirectionBadge direction={w.direction} />
|
||||
<StatusBadge window={w} />
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -12,4 +12,5 @@ export { ShipmentsSection } from "./ShipmentsSection";
|
||||
export { StatKpi } from "./StatKpi";
|
||||
export { StatsSection } from "./StatsSection";
|
||||
export { Stepper } from "./Stepper";
|
||||
export { UpcomingWindowsSection } from "./UpcomingWindowsSection";
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@ export function useMyPortalData(selectedProfileId?: string) {
|
||||
api.companies.getDashboard.queryOptions({ input: selectedProfileId }),
|
||||
);
|
||||
|
||||
// Upcoming/open booking windows on the customer's active-contract lanes.
|
||||
// Refetched every minute so "Open now" flips without a manual reload.
|
||||
const bookingWindowsQuery = useQuery(
|
||||
api.bookings.getMyBookingWindows.queryOptions({
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
const bookingWindows = bookingWindowsQuery.data ?? [];
|
||||
|
||||
const contractsQuery = useQuery(
|
||||
api.contracts.list.queryOptions({
|
||||
input: {
|
||||
@@ -95,6 +104,8 @@ export function useMyPortalData(selectedProfileId?: string) {
|
||||
dashboardQuery,
|
||||
contractsQuery,
|
||||
invoicesQuery,
|
||||
bookingWindowsQuery,
|
||||
bookingWindows,
|
||||
allContracts,
|
||||
recentContracts,
|
||||
activeContractsCount,
|
||||
|
||||
@@ -125,6 +125,46 @@ function Countdown({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Partial-capacity batch offer ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Present on the booking (status SELECTED_FOR_BATCH) when only part of it fit
|
||||
* the train. Paying accepts the split; not paying keeps the booking whole and
|
||||
* it expires for this train. Local extension — not yet in @edr/types.
|
||||
*/
|
||||
interface ActiveBatchOffer {
|
||||
offeredWagons: number;
|
||||
totalWagons: number;
|
||||
offeredAmount: number;
|
||||
paymentDeadline: string;
|
||||
}
|
||||
|
||||
function PartialOfferNotice({ offer }: { offer: ActiveBatchOffer }) {
|
||||
const remaining = offer.totalWagons - offer.offeredWagons;
|
||||
return (
|
||||
<Box
|
||||
mt={14}
|
||||
p={14}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#FEF6E6",
|
||||
border: "1px solid #F3E2B8",
|
||||
}}
|
||||
>
|
||||
<Text fz="13px" fw={800} c="#9A5B00">
|
||||
Partial allocation offer
|
||||
</Text>
|
||||
<Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}>
|
||||
{offer.offeredWagons} of {offer.totalWagons} wagons fit this train.
|
||||
Paying accepts the split — the remaining {remaining} wagon
|
||||
{remaining === 1 ? "" : "s"} return to your contract to book in a later
|
||||
window. If you don't pay before the deadline, your booking stays
|
||||
whole and can be rebooked next window.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Merged payment panel ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -140,7 +180,7 @@ export function BookingPaymentPanel({
|
||||
paying,
|
||||
showCountdown,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
booking: Freight.IBooking & { activeBatchOffer?: ActiveBatchOffer | null };
|
||||
pricing: Pricing;
|
||||
onPay?: () => void;
|
||||
paying?: boolean;
|
||||
@@ -217,6 +257,10 @@ export function BookingPaymentPanel({
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!paid && booking.activeBatchOffer && (
|
||||
<PartialOfferNotice offer={booking.activeBatchOffer} />
|
||||
)}
|
||||
|
||||
{showCountdown && booking.paymentDeadline && (
|
||||
<Box mt={16}>
|
||||
<Countdown
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
||||
import { filterBookableServices } from "./helpers";
|
||||
import { filterBookableServices, operationToTradeDirection } from "./helpers";
|
||||
import { fieldStyles, StepLabel } from "./shared";
|
||||
import { PaymentCurrencyField } from "./payment-currency-field";
|
||||
import { LocationPicker } from "@/pages/bookings/new-booking-form/LocationPicker";
|
||||
@@ -241,8 +241,18 @@ export function Step2ServiceType({
|
||||
(s) => s.id === serviceTypeId,
|
||||
);
|
||||
|
||||
const { includesCustoms, includesFirstMile, includesLastMile } =
|
||||
serviceType ?? {};
|
||||
const { includesCustoms } = serviceType ?? {};
|
||||
|
||||
// Import contracts never truck the first mile (goods arrive at the port);
|
||||
// export contracts never truck the last mile. Hide the irrelevant toggle by
|
||||
// trade direction, regardless of what the service bundles.
|
||||
const tradeDirection = operationType
|
||||
? operationToTradeDirection(operationType)
|
||||
: null;
|
||||
const includesFirstMile =
|
||||
(serviceType?.includesFirstMile ?? false) && tradeDirection !== "IMPORT";
|
||||
const includesLastMile =
|
||||
(serviceType?.includesLastMile ?? false) && tradeDirection !== "EXPORT";
|
||||
const firstMileEnabled = form.watch("firstMile.enabled");
|
||||
const lastMileEnabled = form.watch("lastMile.enabled");
|
||||
|
||||
@@ -290,6 +300,26 @@ export function Step2ServiceType({
|
||||
}
|
||||
}, [serviceType, includesFirstMile, includesLastMile, includesCustoms, form]);
|
||||
|
||||
// A hidden mile must not leak a stale enabled=true into the payload. The
|
||||
// effect above only fires on service change; switching operation type (import
|
||||
// ⇄ export) hides a mile without touching the service, so clear it here too.
|
||||
useEffect(() => {
|
||||
if (!includesFirstMile && form.getValues("firstMile.enabled")) {
|
||||
form.setValue(
|
||||
"firstMile",
|
||||
{ enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
}
|
||||
if (!includesLastMile && form.getValues("lastMile.enabled")) {
|
||||
form.setValue(
|
||||
"lastMile",
|
||||
{ enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
}
|
||||
}, [includesFirstMile, includesLastMile, form]);
|
||||
|
||||
const showServiceSections =
|
||||
serviceType != null || includesFirstMile || includesLastMile;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CreateBookingPayload,
|
||||
type CustomerTruckAssignmentPayload,
|
||||
GeneratePriceResponse,
|
||||
type MyBookingWindow,
|
||||
SubmitBookingResponse,
|
||||
} from "./bookings.service";
|
||||
import {
|
||||
@@ -358,6 +359,12 @@ export const api = {
|
||||
"availableDaysForCargo",
|
||||
(input) => bookingsService.getAvailableDaysForCargo(input),
|
||||
),
|
||||
|
||||
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||
"train-scheduling",
|
||||
"myBookingWindows",
|
||||
() => bookingsService.getMyBookingWindows(),
|
||||
),
|
||||
},
|
||||
|
||||
contracts: {
|
||||
|
||||
@@ -46,6 +46,25 @@ export interface PriceLineItem {
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An upcoming/open booking window on one of the signed-in customer's
|
||||
* active-contract lanes. Import trains open a window on one booking day;
|
||||
* export trains open 24h before departure (first come, first served).
|
||||
*/
|
||||
export interface MyBookingWindow {
|
||||
scheduleId: string;
|
||||
direction: "IMPORT" | "EXPORT" | null;
|
||||
windowPhase: string | null;
|
||||
isOpenNow: boolean;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
}
|
||||
|
||||
export interface GeneratePriceResponse {
|
||||
bookingId: string;
|
||||
totalAmount: number;
|
||||
@@ -341,4 +360,15 @@ export const bookingsService = {
|
||||
);
|
||||
return (data.data as Freight.AvailableDaysResponse).days;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upcoming/open booking windows on the signed-in customer's active-contract
|
||||
* lanes (import booking-day windows + export 24h pre-departure windows).
|
||||
*/
|
||||
getMyBookingWindows: async (): Promise<MyBookingWindow[]> => {
|
||||
const { data } = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MY_BOOKING_WINDOWS,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user