Merge branch 'contrat-backup2' of github.com:Tria-plc/edr-platform into contrat-backup2

This commit is contained in:
marshal
2026-07-03 06:39:02 +03:00
41 changed files with 2480 additions and 124 deletions

View File

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

View File

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

View File

@@ -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>
);
});

View File

@@ -12,4 +12,5 @@ export { ShipmentsSection } from "./ShipmentsSection";
export { StatKpi } from "./StatKpi";
export { StatsSection } from "./StatsSection";
export { Stepper } from "./Stepper";
export { UpcomingWindowsSection } from "./UpcomingWindowsSection";

View File

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

View File

@@ -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&apos;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

View File

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

View File

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

View File

@@ -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;
},
};