mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
style: inter module integration
This commit is contained in:
@@ -413,6 +413,32 @@ export class BillingService {
|
||||
return `data:image/png;base64,${signedQr}`;
|
||||
}
|
||||
|
||||
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
|
||||
private async bookingSummaryRows(
|
||||
invoice: Invoice,
|
||||
): Promise<InvoiceDocumentModel["summary"]> {
|
||||
if (invoice.source !== Freight.InvoiceSource.Booking) return [];
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: invoice.sourceId },
|
||||
relations: { originYard: true, destinationYard: true },
|
||||
});
|
||||
if (!booking) return [];
|
||||
return [
|
||||
{
|
||||
label: "Route",
|
||||
value:
|
||||
booking.originYard && booking.destinationYard
|
||||
? `${booking.originYard.label} → ${booking.destinationYard.label}`
|
||||
: null,
|
||||
},
|
||||
{
|
||||
label: "Wagons",
|
||||
value:
|
||||
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
||||
private async toDocumentModel(
|
||||
invoice: Invoice & { lines: InvoiceLine[] },
|
||||
@@ -446,6 +472,7 @@ export class BillingService {
|
||||
{ label: "Status", value: invoice.status },
|
||||
{ label: "Type", value: invoice.type },
|
||||
{ label: "Reference", value: invoice.sourceId },
|
||||
...(await this.bookingSummaryRows(invoice)),
|
||||
{ label: "Currency", value: invoice.currency },
|
||||
{
|
||||
label: "Issued",
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface BookingListFilterOptions {
|
||||
assignedToSchedule?: 'true' | 'false';
|
||||
companyId?: string;
|
||||
companyProfileId?: string;
|
||||
contractId?: string;
|
||||
contractType?: string;
|
||||
serviceTypeId?: string;
|
||||
cargoTypeId?: string;
|
||||
@@ -936,6 +937,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
companyProfileId: options.companyProfileId,
|
||||
});
|
||||
}
|
||||
if (options.contractId) {
|
||||
qb.andWhere('booking.contract_id = :contractId', {
|
||||
contractId: options.contractId,
|
||||
});
|
||||
}
|
||||
if (options.contractType) {
|
||||
qb.andWhere('booking.contract_type = :contractType', {
|
||||
contractType: options.contractType,
|
||||
|
||||
@@ -1805,6 +1805,7 @@ export class BookingsService {
|
||||
// ANDs both, so cross-company access is impossible.
|
||||
companyId: forceCompanyId ?? filter.companyId,
|
||||
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
|
||||
contractId: filter.contractId,
|
||||
tradeDirections,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
|
||||
@@ -47,6 +47,11 @@ export class FilterBookingDto {
|
||||
@IsUUID()
|
||||
companyProfileId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Filter bookings drawn down under this contract' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
contractId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
contractType?: string;
|
||||
|
||||
@@ -1,44 +1,16 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Building2,
|
||||
FileCheck,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Group, Stack, Text, Divider } from "@mantine/core";
|
||||
import { Building2, FileCheck, Mail, MapPin, Phone, User } from "lucide-react";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { LinkedEntityCard } from "@/components/detail";
|
||||
import type { FieldRowProps } from "@/components/detail";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
interface InfoRowProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Icon size={15} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export interface BookingCompanyCardProps {
|
||||
booking: BookingDetail;
|
||||
}
|
||||
|
||||
/** Customer (company) information for the booking. */
|
||||
/** Customer (company) quick info for the booking, linking to its detail page. */
|
||||
export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
|
||||
const company = booking.company;
|
||||
|
||||
@@ -46,11 +18,9 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
|
||||
if (!company && booking.isGovernment) {
|
||||
return (
|
||||
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||
<InfoRow
|
||||
icon={Building2}
|
||||
label="Government"
|
||||
value={booking.governmentInstitution}
|
||||
/>
|
||||
<Text size="sm" fw={600}>
|
||||
{booking.governmentInstitution ?? "Government"}
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -67,36 +37,24 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
|
||||
|
||||
const companyName = company.companyName ?? company.name ?? company.label;
|
||||
|
||||
const rows: InfoRowProps[] = [
|
||||
const rows: FieldRowProps[] = [
|
||||
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||
{ icon: Mail, label: "Email", value: company.email },
|
||||
{ icon: Phone, label: "Phone", value: company.phone },
|
||||
{ icon: MapPin, label: "Address", value: company.address },
|
||||
{ icon: User, label: "Contact person", value: company.contactPersonName },
|
||||
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
|
||||
].filter((r) => r.value);
|
||||
];
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
<LinkedEntityCard
|
||||
icon={Building2}
|
||||
title="Customer"
|
||||
subtitle={companyName}
|
||||
name={companyName ?? "Unnamed company"}
|
||||
to={company.id ? `/dashboard/customers/${company.id}` : null}
|
||||
accent="blue"
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{rows.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No additional company details available.
|
||||
</Text>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<div key={row.label}>
|
||||
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
|
||||
<InfoRow {...row} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
rows={rows}
|
||||
emptyMessage="No additional company details available."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Anchor as AnchorIcon } from "lucide-react";
|
||||
import { Code } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { LinkedEntityCard } from "@/components/detail";
|
||||
import type { FieldRowProps } from "@/components/detail";
|
||||
|
||||
export interface BookingContractCardProps {
|
||||
booking: BookingDetail;
|
||||
}
|
||||
|
||||
/** Parent contract quick info for the booking, linking to its detail page. */
|
||||
export function BookingContractCard({ booking }: BookingContractCardProps) {
|
||||
if (!booking.contractId || !booking.contractReference) return null;
|
||||
|
||||
const rows: FieldRowProps[] = [
|
||||
{
|
||||
label: "Kind",
|
||||
value: booking.contractKind === "GENERAL" ? "General" : "One-time",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<LinkedEntityCard
|
||||
icon={AnchorIcon}
|
||||
title="Contract"
|
||||
name={booking.contractReference}
|
||||
to={`/dashboard/contract-requests/${booking.contractId}`}
|
||||
accent="teal"
|
||||
rows={rows}
|
||||
footer={
|
||||
booking.contractSummary ? (
|
||||
<Code
|
||||
block
|
||||
mt={4}
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
overflow: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
{booking.contractSummary}
|
||||
</Code>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Anchor } from "lucide-react";
|
||||
import { Code } from "@mantine/core";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
export interface BookingContractSummaryCardProps {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/** Generated contract terms, shown verbatim. */
|
||||
export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) {
|
||||
return (
|
||||
<SectionCard icon={Anchor} title="Contract summary" accent="teal">
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
maxHeight: 256,
|
||||
overflow: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
</Code>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Truck } from "lucide-react";
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
import { SimpleGrid, Stack } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
@@ -8,24 +9,40 @@ import { MetricTile } from "./MetricTile";
|
||||
|
||||
export interface BookingMileServicesCardProps {
|
||||
booking: BookingDetail;
|
||||
/** Export handover-mode control — how the cargo reaches the train. Lives
|
||||
* here because it's the other "how does the cargo physically travel" fact;
|
||||
* shown even when no mile address is set, since EXPORT bookings still need
|
||||
* the choice made. */
|
||||
handoverSection?: ReactNode;
|
||||
}
|
||||
|
||||
/** First / last mile addresses. Renders nothing when neither is present. */
|
||||
export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) {
|
||||
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
|
||||
/** First / last mile addresses, plus the export handover control. Renders
|
||||
* nothing when none of the three are present. */
|
||||
export function BookingMileServicesCard({
|
||||
booking,
|
||||
handoverSection,
|
||||
}: BookingMileServicesCardProps) {
|
||||
const hasAddresses =
|
||||
Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress);
|
||||
if (!hasAddresses && !handoverSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard icon={Truck} title="Mile services" accent="grape">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
|
||||
{booking.firstMilePickupAddress && (
|
||||
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
|
||||
<Stack gap="md">
|
||||
{hasAddresses && (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
|
||||
{booking.firstMilePickupAddress && (
|
||||
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
|
||||
)}
|
||||
{booking.lastMileDeliveryAddress && (
|
||||
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{booking.lastMileDeliveryAddress && (
|
||||
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
{handoverSection}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
Calendar,
|
||||
Clock,
|
||||
Container as ContainerIcon,
|
||||
Flame,
|
||||
RefreshCw,
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
|
||||
import { formatDate } from "./booking-detail.styles";
|
||||
|
||||
export interface BookingRequestHeroProps {
|
||||
booking: BookingDetail;
|
||||
customerLabel: string;
|
||||
onBack: () => void;
|
||||
onRefresh: () => void;
|
||||
isFetching?: boolean;
|
||||
}
|
||||
|
||||
/** Top hero for the request detail page: identity, status, next step, key figures. */
|
||||
export function BookingRequestHero({
|
||||
booking,
|
||||
customerLabel,
|
||||
onBack,
|
||||
onRefresh,
|
||||
isFetching,
|
||||
}: BookingRequestHeroProps) {
|
||||
const amount = Number(booking.totalAmount);
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const containerCount = containers.reduce(
|
||||
(sum, c) => sum + Number(c.quantity ?? 0),
|
||||
0,
|
||||
);
|
||||
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{ position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={onBack}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
loading={isFetching}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 1, color: "#B26C09" }}
|
||||
>
|
||||
Booking reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Stack gap={2} miw={0}>
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
{booking.reference}
|
||||
</Title>
|
||||
<ContractReferenceLink
|
||||
contractId={booking.contractId}
|
||||
contractReference={booking.contractReference}
|
||||
/>
|
||||
</Stack>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
{booking.schedulingStatus ? (
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
<Text size="xs" c="orange.7">
|
||||
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Group gap="lg" mt={4}>
|
||||
<MetaItem icon={Building2} text={customerLabel} strong />
|
||||
<MetaItem
|
||||
icon={Calendar}
|
||||
text={`Scheduled ${booking.scheduledDate}`}
|
||||
/>
|
||||
<MetaItem
|
||||
icon={Clock}
|
||||
text={`Created ${formatDate(booking.createdAt)}`}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{booking.nextStep ? (
|
||||
<Paper
|
||||
radius="lg"
|
||||
p={4}
|
||||
maw={640}
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<NextStepBanner nextStep={booking.nextStep} />
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Group grow gap="md" align="stretch" wrap="wrap">
|
||||
<HeroTile
|
||||
icon={Wallet}
|
||||
label="Total value"
|
||||
value={`${booking.paymentCurrency} ${amount.toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
minimumFractionDigits: 2,
|
||||
},
|
||||
)}`}
|
||||
hint={booking.paymentStatus}
|
||||
accent="edr-green"
|
||||
/>
|
||||
<HeroTile
|
||||
icon={Weight}
|
||||
label="Cargo weight"
|
||||
value={`${weight} T`}
|
||||
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
|
||||
accent="blue"
|
||||
/>
|
||||
<HeroTile
|
||||
icon={ContainerIcon}
|
||||
label="Containers"
|
||||
value={containerCount || "—"}
|
||||
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
|
||||
accent="teal"
|
||||
/>
|
||||
<HeroTile
|
||||
icon={Flame}
|
||||
label="Priority score"
|
||||
value={booking.priorityScore ?? 0}
|
||||
hint={booking.tradeDirection}
|
||||
accent="orange"
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({
|
||||
icon: Icon,
|
||||
text,
|
||||
strong,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
text: ReactNode;
|
||||
strong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Icon size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "dark" : "dimmed"}>
|
||||
{text}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
accent = "edr-green",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
style={{
|
||||
flex: "1 1 160px",
|
||||
minWidth: 150,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={36} radius="md" variant="light" color={accent}>
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
style={{ letterSpacing: 0.4 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>
|
||||
{value}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -16,10 +16,9 @@ export * from "./BookingPaymentCard";
|
||||
export * from "./BookingPaymentCountdownCard";
|
||||
export * from "./BookingFactsCard";
|
||||
export * from "./BookingDocumentsCard";
|
||||
export * from "./BookingRequestHero";
|
||||
export * from "./BookingRouteServiceCard";
|
||||
export * from "./BookingMileServicesCard";
|
||||
export * from "./BookingCargoCard";
|
||||
export * from "./BookingContractSummaryCard";
|
||||
export * from "./BookingContractCard";
|
||||
export * from "./BookingCompanyCard";
|
||||
export * from "./BookingSchedulingWindowCard";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "edr-green",
|
||||
ACCEPTED: "blue",
|
||||
REJECTED: "red",
|
||||
};
|
||||
|
||||
/** Status of a customer-submitted shipment (booking) request against a contract. */
|
||||
export function BookingRequestStatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<Badge variant="light" radius="sm" color={STATUS_COLOR[status] ?? "gray"}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { clearanceWorkflowFileLabel } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
||||
import { LinkedEntityCard } from "@/components/detail";
|
||||
import { customersService } from "@/services/customers.service";
|
||||
|
||||
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
||||
@@ -141,25 +142,23 @@ export function ContractCustomerCard({
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
<LinkedEntityCard
|
||||
icon={Building2}
|
||||
title="Customer"
|
||||
subtitle={company.name}
|
||||
name={company.name ?? "Unnamed company"}
|
||||
to={`/dashboard/customers/${company.id}`}
|
||||
accent="blue"
|
||||
>
|
||||
<InfoRows
|
||||
rows={[
|
||||
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||
{ icon: Hash, label: "VAT number", value: company.vatNumber },
|
||||
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
|
||||
{ icon: Globe, label: "Country", value: company.country },
|
||||
{ icon: Mail, label: "Email", value: company.email },
|
||||
{ icon: Phone, label: "Phone", value: company.phone },
|
||||
{ icon: MapPin, label: "Address", value: company.address },
|
||||
{ icon: Globe, label: "Website", value: company.website },
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
rows={[
|
||||
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||
{ icon: Hash, label: "VAT number", value: company.vatNumber },
|
||||
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
|
||||
{ icon: Globe, label: "Country", value: company.country },
|
||||
{ icon: Mail, label: "Email", value: company.email },
|
||||
{ icon: Phone, label: "Phone", value: company.phone },
|
||||
{ icon: MapPin, label: "Address", value: company.address },
|
||||
{ icon: Globe, label: "Website", value: company.website },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SectionCard icon={User} title="Contact person" accent="teal">
|
||||
<InfoRows
|
||||
|
||||
@@ -12,56 +12,14 @@ import {
|
||||
User,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core";
|
||||
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { LinkedEntityCard } from "@/components/detail";
|
||||
|
||||
type ReqContract = NonNullable<Freight.IBookingRequest["contract"]>;
|
||||
|
||||
interface InfoRowProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Icon size={15} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
|
||||
const visible = rows.filter((r) => r.value);
|
||||
if (visible.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No details available.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{visible.map((row, i) => (
|
||||
<div key={row.label}>
|
||||
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
|
||||
<InfoRow {...row} />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer (company) on the request's contract. */
|
||||
export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) {
|
||||
const company = contract?.company;
|
||||
@@ -75,23 +33,21 @@ export function RequestCustomerCard({ contract }: { contract?: ReqContract | nul
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SectionCard
|
||||
<LinkedEntityCard
|
||||
icon={Building2}
|
||||
title="Customer"
|
||||
subtitle={company.name ?? undefined}
|
||||
name={company.name ?? "Unnamed company"}
|
||||
to={company.id ? `/dashboard/customers/${company.id}` : null}
|
||||
accent="blue"
|
||||
>
|
||||
<InfoRows
|
||||
rows={[
|
||||
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||
{ icon: Mail, label: "Email", value: company.email },
|
||||
{ icon: Phone, label: "Phone", value: company.phone },
|
||||
{ icon: MapPin, label: "Address", value: company.address },
|
||||
{ icon: User, label: "Contact", value: company.contactPersonName },
|
||||
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
rows={[
|
||||
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||
{ icon: Mail, label: "Email", value: company.email },
|
||||
{ icon: Phone, label: "Phone", value: company.phone },
|
||||
{ icon: MapPin, label: "Address", value: company.address },
|
||||
{ icon: User, label: "Contact", value: company.contactPersonName },
|
||||
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,43 +75,41 @@ export function RequestContractSummaryCard({
|
||||
}) {
|
||||
if (!contract) return null;
|
||||
return (
|
||||
<SectionCard
|
||||
<LinkedEntityCard
|
||||
icon={FileText}
|
||||
title="Contract"
|
||||
subtitle={contract.reference}
|
||||
name={contract.reference}
|
||||
to={`/dashboard/contract-requests/${contract.id}`}
|
||||
accent="grape"
|
||||
>
|
||||
<InfoRows
|
||||
rows={[
|
||||
{
|
||||
icon: FileText,
|
||||
label: "Kind",
|
||||
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
label: "Cargo",
|
||||
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
|
||||
},
|
||||
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
|
||||
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
|
||||
{
|
||||
icon: FileCheck,
|
||||
label: "Customs",
|
||||
value: contract.customsClearingEnabled
|
||||
? "Included (Global Logistics)"
|
||||
: "Not included",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
label: "Valid until",
|
||||
value: contract.contractValidUntil
|
||||
? fmtDate(contract.contractValidUntil)
|
||||
: "Not active yet",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
rows={[
|
||||
{
|
||||
icon: FileText,
|
||||
label: "Kind",
|
||||
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
label: "Cargo",
|
||||
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
|
||||
},
|
||||
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
|
||||
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
|
||||
{
|
||||
icon: FileCheck,
|
||||
label: "Customs",
|
||||
value: contract.customsClearingEnabled
|
||||
? "Included (Global Logistics)"
|
||||
: "Not included",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
label: "Valid until",
|
||||
value: contract.contractValidUntil
|
||||
? fmtDate(contract.contractValidUntil)
|
||||
: "Not active yet",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Alert, Button, Loader, Modal, Radio, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, ResetChannel } from "@/types/customer";
|
||||
|
||||
export interface ResetPasswordActionProps {
|
||||
company: Pick<Company, "id">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-triggered password reset. Sends a single-use link to the customer's
|
||||
* primary contact; the customer opens it and picks their own new password. No
|
||||
* credential is ever shown to or handled by staff.
|
||||
*/
|
||||
export default function ResetPasswordAction({
|
||||
company,
|
||||
}: ResetPasswordActionProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [channel, setChannel] = useState<ResetChannel>("phone");
|
||||
|
||||
const allowed = hasPermission(user, FREIGHT_PERMS.customers.resetPassword);
|
||||
|
||||
// The destination is the primary contact's IAM account, not the company
|
||||
// record — those are different fields and routinely hold different values, so
|
||||
// showing `company.phone` here would tell staff the wrong number. Only fetched
|
||||
// once the modal is open.
|
||||
const targetQuery = useQuery(
|
||||
api.customers.resetTarget.queryOptions({
|
||||
input: { companyId: company.id },
|
||||
enabled: allowed && opened,
|
||||
}),
|
||||
);
|
||||
const target = targetQuery.data;
|
||||
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.customers.resetPassword.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setOpened(false);
|
||||
toast({
|
||||
title: "Reset link sent",
|
||||
description: `The customer can set a new password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Could not send reset link",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
// SMS is domestic-only: a foreign number counts as unavailable, same as a
|
||||
// missing one, so staff can't send a link that will never arrive.
|
||||
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
|
||||
const channelMissing =
|
||||
!!target && (channel === "email" ? !target.email : !phoneUsable);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<KeyRound size={16} />}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Send a password-reset link"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
We'll send a single-use link to this customer's primary
|
||||
contact. They choose their own new password — you will not see it.
|
||||
The link expires in 24 hours.
|
||||
</Text>
|
||||
|
||||
{targetQuery.isLoading ? (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Stack>
|
||||
) : targetQuery.isError ? (
|
||||
<Alert color="red" variant="light">
|
||||
{targetQuery.error.message}
|
||||
</Alert>
|
||||
) : target ? (
|
||||
<>
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as ResetChannel)}
|
||||
label={`Send the link to ${target.name || "the primary contact"} via`}
|
||||
>
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Radio
|
||||
value="phone"
|
||||
label="SMS"
|
||||
disabled={!phoneUsable}
|
||||
description={
|
||||
!target.phone
|
||||
? "No phone number on this account"
|
||||
: target.phoneIsDomestic === false
|
||||
? `${target.phone} — foreign number, SMS unavailable; use email`
|
||||
: target.phone
|
||||
}
|
||||
/>
|
||||
<Radio
|
||||
value="email"
|
||||
label="Email"
|
||||
disabled={!target.email}
|
||||
description={
|
||||
target.email ?? "No email address on this account"
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
These are the primary contact's own login details, which may
|
||||
differ from the company contact details on the profile.
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={isPending}
|
||||
disabled={channelMissing}
|
||||
onClick={() => mutate({ companyId: company.id, channel })}
|
||||
>
|
||||
Send reset link
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,10 +19,6 @@ export {
|
||||
RequestDocumentChangeModal,
|
||||
type RequestDocumentChangeModalProps,
|
||||
} from "./RequestDocumentChangeModal";
|
||||
export {
|
||||
default as ResetPasswordAction,
|
||||
type ResetPasswordActionProps,
|
||||
} from "./ResetPasswordAction";
|
||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||
export {
|
||||
PersonCard,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { Anchor, Group, Text } from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export interface EntityLinkProps {
|
||||
/** Route to the related record's detail page. Renders nothing if falsy — a
|
||||
* link with no id would be a dead one (e.g. a government booking with no
|
||||
* company). */
|
||||
to?: string | null;
|
||||
label: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
/** Monospace label — for references/codes (e.g. "CT-2024-0117"). */
|
||||
mono?: boolean;
|
||||
size?: "xs" | "sm" | "md";
|
||||
fw?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline link to another record's detail page, with a small "go to" glyph so
|
||||
* it reads as navigation rather than plain emphasis. `stopPropagation` matters
|
||||
* wherever this sits inside a clickable table row (booking/invoice rows
|
||||
* navigate on click) — without it a nested link races the row handler.
|
||||
*/
|
||||
export function EntityLink({
|
||||
to,
|
||||
label,
|
||||
icon: Icon,
|
||||
mono,
|
||||
size = "sm",
|
||||
fw = 600,
|
||||
className,
|
||||
}: EntityLinkProps) {
|
||||
if (!to) {
|
||||
return (
|
||||
<Text size={size} fw={fw} c="dimmed" ff={mono ? "monospace" : undefined}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={to}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
underline="hover"
|
||||
c="edr-green"
|
||||
fw={fw}
|
||||
fz={size}
|
||||
ff={mono ? "monospace" : undefined}
|
||||
className={className}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap" component="span" style={{ display: "inline-flex" }}>
|
||||
{Icon ? <Icon size={14} /> : null}
|
||||
<span>{label}</span>
|
||||
<ArrowUpRight size={13} style={{ flexShrink: 0 }} />
|
||||
</Group>
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
export interface FieldProps {
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacked label-over-value pair — uppercase dimmed label, value below. Used in
|
||||
* grids of facts (e.g. an invoice summary, a contract's key figures).
|
||||
*/
|
||||
export function Field({ label, value }: FieldProps) {
|
||||
const isEmpty = value === undefined || value === null || value === "";
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c="edr-muted"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.04em" }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" c="edr-text">
|
||||
{isEmpty ? "—" : value}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FieldRowProps {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Left icon+label / right bold value row, divider-separated when stacked in a
|
||||
* list. Used inside quick-info cards (see `LinkedEntityCard`).
|
||||
*/
|
||||
export function FieldRow({ icon: Icon, label, value }: FieldRowProps) {
|
||||
const isEmpty = value === undefined || value === null || value === "";
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{Icon ? <Icon size={15} color="var(--mantine-color-gray-5)" /> : null}
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
|
||||
{isEmpty ? "—" : value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Divider, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { FieldRow, type FieldRowProps } from "./Field";
|
||||
import { EntityLink } from "./EntityLink";
|
||||
|
||||
export interface LinkedEntityCardProps {
|
||||
icon: LucideIcon;
|
||||
/** Card title, e.g. "Customer" or "Contract". */
|
||||
title: string;
|
||||
/** The entity's own name/reference, rendered as the linked subtitle. */
|
||||
name: ReactNode;
|
||||
/** Route to the entity's detail page. Omit when there's nothing to link to
|
||||
* (e.g. a government booking with no company) — the name renders as plain
|
||||
* dimmed text instead of a dead link. */
|
||||
to?: string | null;
|
||||
accent?: string;
|
||||
/** Quick-info rows shown below the linked name — empty ones are dropped. */
|
||||
rows?: FieldRowProps[];
|
||||
/** Extra content under the rows (e.g. a summary paragraph, an action). */
|
||||
footer?: ReactNode;
|
||||
/** Shown instead of rows/footer when there's nothing to display at all. */
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Customer at a glance" / "Contract at a glance" card for a detail page's
|
||||
* sticky rail: a linked title plus a handful of quick-info rows, so the
|
||||
* related record's essentials are visible without navigating away.
|
||||
*/
|
||||
export function LinkedEntityCard({
|
||||
icon,
|
||||
title,
|
||||
name,
|
||||
to,
|
||||
accent = "blue",
|
||||
rows = [],
|
||||
footer,
|
||||
emptyMessage,
|
||||
}: LinkedEntityCardProps) {
|
||||
const visibleRows = rows.filter((r) => r.value !== undefined && r.value !== null && r.value !== "");
|
||||
|
||||
return (
|
||||
<SectionCard icon={icon} title={title} accent={accent}>
|
||||
<Stack gap={4}>
|
||||
<EntityLink to={to} label={name} size="sm" fw={700} />
|
||||
{visibleRows.length > 0 ? (
|
||||
<Stack gap={0} mt={4}>
|
||||
{visibleRows.map((row, index) => (
|
||||
<div key={row.label}>
|
||||
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
|
||||
<FieldRow {...row} />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
) : emptyMessage ? (
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
{footer}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { Field, FieldRow } from "./Field";
|
||||
export type { FieldProps, FieldRowProps } from "./Field";
|
||||
export { EntityLink } from "./EntityLink";
|
||||
export type { EntityLinkProps } from "./EntityLink";
|
||||
export { LinkedEntityCard } from "./LinkedEntityCard";
|
||||
export type { LinkedEntityCardProps } from "./LinkedEntityCard";
|
||||
|
||||
// Re-exported so pages under this restructure have one import path for both
|
||||
// the new quick-info primitives and the existing section-card shell. Imported
|
||||
// from the file directly (not the bookings/detail barrel) — that barrel also
|
||||
// re-exports cards that import from this module, and going through it would
|
||||
// create a circular import.
|
||||
export { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
export type { SectionCardProps } from "@/components/bookings/detail/SectionCard";
|
||||
@@ -7,7 +7,7 @@ import Breadcrumbs, { type BreadcrumbItem } from "@/components/ui/Breadcrumbs";
|
||||
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
subtitle?: ReactNode;
|
||||
/** Breadcrumb trail — pass only on nested pages (details, sub-resources). */
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
/** Route to return to; renders a back arrow before the title. */
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react";
|
||||
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
/**
|
||||
@@ -254,15 +255,19 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
{e.bookings.map((b) => (
|
||||
<Table.Tr key={b.bookingId}>
|
||||
<Table.Td w="50%">
|
||||
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
|
||||
{b.reference}
|
||||
<Group gap={4} wrap="nowrap" style={{ whiteSpace: "nowrap" }}>
|
||||
<EntityLink
|
||||
to={`/dashboard/booking-requests/${b.bookingId}`}
|
||||
label={b.reference}
|
||||
size="sm"
|
||||
fw={500}
|
||||
/>
|
||||
{b.route ? (
|
||||
<Text span size="xs" c="dimmed">
|
||||
{" "}
|
||||
({b.route})
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w="25%">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
@@ -601,6 +602,7 @@ export function ScheduleWorkspacePanel({
|
||||
{pool.map((b) => (
|
||||
<BookingCard
|
||||
key={b.id}
|
||||
bookingId={b.id}
|
||||
reference={b.reference}
|
||||
customer={b.customer}
|
||||
weightTons={b.weightTons}
|
||||
@@ -711,6 +713,7 @@ export function ScheduleWorkspacePanel({
|
||||
return (
|
||||
<BookingCard
|
||||
key={b.id}
|
||||
bookingId={b.id}
|
||||
reference={ref}
|
||||
customer={b.customer}
|
||||
weightTons={b.weightTons}
|
||||
@@ -985,6 +988,7 @@ function PanelColumn({
|
||||
}
|
||||
|
||||
function BookingCard({
|
||||
bookingId,
|
||||
reference,
|
||||
customer,
|
||||
weightTons,
|
||||
@@ -996,6 +1000,8 @@ function BookingCard({
|
||||
leg,
|
||||
right,
|
||||
}: {
|
||||
/** When set, the reference links to the booking's detail page. */
|
||||
bookingId?: string;
|
||||
reference: string;
|
||||
customer?: string | null;
|
||||
weightTons?: number | null;
|
||||
@@ -1030,9 +1036,18 @@ function BookingCard({
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
|
||||
<Stack gap={3} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} align="center" wrap="nowrap">
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{reference}
|
||||
</Text>
|
||||
{bookingId ? (
|
||||
<EntityLink
|
||||
to={`/dashboard/booking-requests/${bookingId}`}
|
||||
label={reference}
|
||||
size="sm"
|
||||
fw={700}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{reference}
|
||||
</Text>
|
||||
)}
|
||||
{status ? <BookingStatusBadge status={status} /> : null}
|
||||
{intercity ? (
|
||||
<Tooltip
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { Box, Package } from "lucide-react";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
|
||||
|
||||
type WagonSlot = (WagonPlanRow & {
|
||||
@@ -159,9 +160,12 @@ export function WagonPlanGrid({
|
||||
<Card key={`${alloc.bookingId}-${index}`} padding="xs" radius="md" bg="gray.0">
|
||||
<Stack gap={2}>
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="xs" fw={500} lineClamp={1}>
|
||||
{alloc.bookingReference ?? alloc.bookingId}
|
||||
</Text>
|
||||
<EntityLink
|
||||
to={`/dashboard/booking-requests/${alloc.bookingId}`}
|
||||
label={alloc.bookingReference ?? alloc.bookingId}
|
||||
size="xs"
|
||||
fw={500}
|
||||
/>
|
||||
{label === "BULK" ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{alloc.allocatedWeightTons}T cargo
|
||||
|
||||
@@ -81,6 +81,8 @@ export const QUERY_KEYS = {
|
||||
["contracts", "clearance-history", region ?? "ET"] as const,
|
||||
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
||||
capacity: (id: string) => ["contracts", "capacity", id] as const,
|
||||
bookingRequests: (id: string) =>
|
||||
["contracts", "booking-requests", id] as const,
|
||||
bookingMilestones: (bookingId: string) =>
|
||||
["contracts", "booking-milestones", bookingId] as const,
|
||||
bookingIncidents: (bookingId: string) =>
|
||||
|
||||
@@ -2,43 +2,56 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Container as ContainerIcon,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Flame,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Truck,
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Container,
|
||||
Stack,
|
||||
Grid,
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Paper,
|
||||
Button,
|
||||
Box,
|
||||
SegmentedControl,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
||||
import type { KpiItem } from "@/components/page";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
||||
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
|
||||
import {
|
||||
detailStyles,
|
||||
BookingRequestHero,
|
||||
BookingRouteServiceCard,
|
||||
BookingMileServicesCard,
|
||||
BookingCargoCard,
|
||||
BookingCompanyCard,
|
||||
BookingContractSummaryCard,
|
||||
BookingContractCard,
|
||||
BookingContainerUnitsCard,
|
||||
BookingSchedulingWindowCard,
|
||||
BookingDocumentsPanel,
|
||||
@@ -48,6 +61,7 @@ import {
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import {
|
||||
useBookingDetail,
|
||||
@@ -133,7 +147,6 @@ export default function BookingRequestDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const row = toBookingListRow(booking);
|
||||
const statusMeta = getStatusMeta(booking.status);
|
||||
// Clearance review + finalize now lives solely on the Operations "Clearance
|
||||
// Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
|
||||
@@ -159,23 +172,176 @@ export default function BookingRequestDetailPage() {
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const company = booking.company;
|
||||
const customerName = toBookingListRow(booking).customerLabel;
|
||||
|
||||
const amount = Number(booking.totalAmount);
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const containerCount = containers.reduce(
|
||||
(sum, c) => sum + Number(c.quantity ?? 0),
|
||||
0,
|
||||
);
|
||||
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
||||
|
||||
const kpis: KpiItem[] = [
|
||||
{
|
||||
label: "Total value",
|
||||
value: `${booking.paymentCurrency} ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`,
|
||||
hint: booking.paymentStatus,
|
||||
icon: Wallet,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Cargo weight",
|
||||
value: `${weight} T`,
|
||||
hint: itemCount != null ? `${itemCount} items` : "VGM total",
|
||||
icon: Weight,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
label: "Containers",
|
||||
value: containerCount || "—",
|
||||
hint: `${containers.length} line${containers.length === 1 ? "" : "s"}`,
|
||||
icon: ContainerIcon,
|
||||
color: "teal",
|
||||
},
|
||||
{
|
||||
label: "Priority score",
|
||||
value: booking.priorityScore ?? 0,
|
||||
hint: booking.tradeDirection,
|
||||
icon: Flame,
|
||||
color: "orange",
|
||||
},
|
||||
];
|
||||
|
||||
const hasSignableContract = booking.isGovernment && booking.contractSummary;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
<PageHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||
{ label: booking.reference },
|
||||
]}
|
||||
backTo="/dashboard/booking-requests"
|
||||
title={booking.reference}
|
||||
meta={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
{booking.schedulingStatus ? (
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
subtitle={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<EntityLink
|
||||
to={company?.id ? `/dashboard/customers/${company.id}` : null}
|
||||
label={customerName ?? "—"}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
· Scheduled {booking.scheduledDate}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
action={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="lg"
|
||||
radius="md"
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
<Menu position="bottom-end" width={260} withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{hasSignableContract && (
|
||||
<Menu.Item
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
||||
}
|
||||
>
|
||||
View / sign contract
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={15} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob =
|
||||
await bookingsService.downloadCarriageAcceptanceSheet(
|
||||
booking.id,
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `carriage-acceptance-${booking.reference}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Carriage acceptance sheet is not available yet",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Carriage acceptance sheet
|
||||
</Menu.Item>
|
||||
{booking.customsClearingEnabled && (
|
||||
<Menu.Item
|
||||
leftSection={<Milestone size={15} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/clearance`)
|
||||
}
|
||||
>
|
||||
View document clearance
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
<BookingRequestHero
|
||||
booking={booking}
|
||||
customerLabel={row.customerLabel}
|
||||
onBack={() => navigate("/dashboard/booking-requests")}
|
||||
onRefresh={() => refetch()}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
<KpiStrip items={kpis} />
|
||||
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
<Text size="xs" c="orange.7">
|
||||
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{booking.nextStep ? (
|
||||
<Paper
|
||||
radius="lg"
|
||||
p={4}
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<NextStepBanner nextStep={booking.nextStep} />
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<BookingWorkflowStepper
|
||||
status={booking.status}
|
||||
@@ -223,7 +389,7 @@ export default function BookingRequestDetailPage() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<OverviewPanel booking={booking} row={row} />
|
||||
<OverviewPanel booking={booking} onRefetch={refetch} />
|
||||
</Tabs.Panel>
|
||||
{isGeneralContract && (
|
||||
<Tabs.Panel value="orders">
|
||||
@@ -247,6 +413,7 @@ export default function BookingRequestDetailPage() {
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingCompanyCard booking={booking} />
|
||||
<BookingContractCard booking={booking} />
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<Box id="warehouse-payments">
|
||||
<WarehouseInfoCard
|
||||
@@ -265,97 +432,6 @@ export default function BookingRequestDetailPage() {
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
/>
|
||||
{booking.tradeDirection === "EXPORT" && (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
How the cargo reaches the train
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={booking.exportHandoverMode ?? "WAREHOUSE"}
|
||||
data={[
|
||||
{ value: "WAREHOUSE", label: "Warehouse then train" },
|
||||
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
|
||||
]}
|
||||
onChange={async (value) => {
|
||||
try {
|
||||
await bookingsService.setExportHandoverMode(
|
||||
booking.id,
|
||||
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
|
||||
);
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not change the handover mode",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
|
||||
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
|
||||
: "Cargo is received at the warehouse and issued a GRN before loading."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
{booking.isGovernment && booking.contractSummary && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/booking-requests/${booking.id}/contract`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View / sign contract
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<FileText size={16} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob =
|
||||
await bookingsService.downloadCarriageAcceptanceSheet(
|
||||
booking.id,
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `carriage-acceptance-${booking.reference}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Carriage acceptance sheet is not available yet",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Carriage acceptance sheet
|
||||
</Button>
|
||||
{booking.customsClearingEnabled && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<Milestone size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/clearance`)
|
||||
}
|
||||
>
|
||||
View document clearance
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
@@ -368,11 +444,13 @@ export default function BookingRequestDetailPage() {
|
||||
/** The booking's primary detail cards — route, services, cargo, containers. */
|
||||
function OverviewPanel({
|
||||
booking,
|
||||
row,
|
||||
onRefetch,
|
||||
}: {
|
||||
booking: BookingDetail;
|
||||
row: ReturnType<typeof toBookingListRow>;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const row = toBookingListRow(booking);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<BookingRouteServiceCard
|
||||
@@ -380,12 +458,49 @@ function OverviewPanel({
|
||||
originLabel={row.originLabel}
|
||||
destinationLabel={row.destinationLabel}
|
||||
/>
|
||||
<BookingMileServicesCard booking={booking} />
|
||||
<BookingMileServicesCard
|
||||
booking={booking}
|
||||
handoverSection={
|
||||
booking.tradeDirection === "EXPORT" ? (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
How the cargo reaches the train
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
value={booking.exportHandoverMode ?? "WAREHOUSE"}
|
||||
data={[
|
||||
{ value: "WAREHOUSE", label: "Warehouse then train" },
|
||||
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
|
||||
]}
|
||||
onChange={async (value) => {
|
||||
try {
|
||||
await bookingsService.setExportHandoverMode(
|
||||
booking.id,
|
||||
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
|
||||
);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not change the handover mode",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
|
||||
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
|
||||
: "Cargo is received at the warehouse and issued a GRN before loading."}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<BookingCargoCard booking={booking} />
|
||||
<BookingContainerUnitsCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -29,9 +27,13 @@ import {
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail";
|
||||
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
||||
import type { KpiItem } from "@/components/page";
|
||||
import {
|
||||
SectionCard,
|
||||
BookingCompanyCard,
|
||||
BookingContractCard,
|
||||
} from "@/components/bookings/detail";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
@@ -171,6 +173,18 @@ export default function DocumentClearanceDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const direction = booking?.tradeDirection ?? "—";
|
||||
const origin = booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
|
||||
const destination =
|
||||
booking?.destinationYard?.label ?? booking?.destinationYard?.code ?? "Destination";
|
||||
|
||||
const kpis: KpiItem[] = [
|
||||
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
|
||||
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
|
||||
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
|
||||
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
@@ -182,25 +196,46 @@ export default function DocumentClearanceDetailPage() {
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
clearance.allApproved ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
>
|
||||
All approved
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
|
||||
{direction}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<Clock size={13} />}
|
||||
>
|
||||
Review pending
|
||||
</Badge>
|
||||
)
|
||||
{clearance.includesCustoms ? (
|
||||
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
|
||||
Customs
|
||||
</Badge>
|
||||
) : null}
|
||||
{clearance.allApproved ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
>
|
||||
All approved
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<Clock size={13} />}
|
||||
>
|
||||
Review pending
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
subtitle={
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" c="dimmed" fw={600}>
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" c="dimmed" fw={600}>
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
action={
|
||||
canCompleteBooking ? (
|
||||
@@ -233,12 +268,16 @@ export default function DocumentClearanceDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<ClearanceHero
|
||||
booking={booking}
|
||||
clearance={clearance}
|
||||
stats={stats}
|
||||
requestedLines={requestedLines}
|
||||
/>
|
||||
<KpiStrip items={kpis} />
|
||||
|
||||
{requestedLines ? (
|
||||
<Group gap={10} align="center" wrap="wrap">
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
|
||||
Requested cargo
|
||||
</Text>
|
||||
<RequestedCargoChips lines={requestedLines} size="sm" />
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{isPhasedGeneral ? (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
@@ -273,67 +312,54 @@ export default function DocumentClearanceDetailPage() {
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
|
||||
{isPhasedGeneral ? (
|
||||
<PhasedClearanceActionPanel
|
||||
bookingId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="ET"
|
||||
// A bare initiated instance still has no cargo/price — the
|
||||
// stepper's "Create booking" step must read as NOT-yet-created
|
||||
// so it never claims the booking is done before GL completes it.
|
||||
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
|
||||
bookingMilestones={bookingMilestones ?? []}
|
||||
onChanged={() => void refetch()}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
) : (
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
{booking ? <BookingCompanyCard booking={booking} /> : null}
|
||||
{booking ? <BookingContractCard booking={booking} /> : null}
|
||||
{isPhasedGeneral ? (
|
||||
<PhasedClearanceActionPanel
|
||||
bookingId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="ET"
|
||||
// A bare initiated instance still has no cargo/price — the
|
||||
// stepper's "Create booking" step must read as NOT-yet-created
|
||||
// so it never claims the booking is done before GL completes it.
|
||||
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
|
||||
bookingMilestones={bookingMilestones ?? []}
|
||||
onChanged={() => void refetch()}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
) : (
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
}
|
||||
@@ -347,125 +373,3 @@ export default function DocumentClearanceDetailPage() {
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceHero({
|
||||
booking,
|
||||
clearance,
|
||||
stats,
|
||||
requestedLines,
|
||||
}: {
|
||||
booking: ReturnType<typeof useBookingDetail>["data"];
|
||||
clearance: Freight.ClearanceView;
|
||||
stats: { pct: number; approved: number; total: number };
|
||||
requestedLines?: Freight.RequestedShipmentLines | null;
|
||||
}) {
|
||||
const direction = booking?.tradeDirection ?? "—";
|
||||
const origin =
|
||||
booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
|
||||
const destination =
|
||||
booking?.destinationYard?.label ??
|
||||
booking?.destinationYard?.code ??
|
||||
"Destination";
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
||||
<ShieldCheck size={26} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={800} fz={20} c="edr-text" truncate>
|
||||
{booking?.reference ?? "Clearance"}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={direction === "IMPORT" ? "edr-green" : "gray"}
|
||||
radius="sm"
|
||||
>
|
||||
{direction}
|
||||
</Badge>
|
||||
{clearance.includesCustoms ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={12} />}
|
||||
>
|
||||
Customs
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap={8} mt={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Document review
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stats.approved}/{stats.total}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{requestedLines ? (
|
||||
<>
|
||||
<Box my="md" h={1} bg="var(--mantine-color-default-border)" />
|
||||
<Group gap={10} align="center" wrap="wrap">
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
|
||||
Requested cargo
|
||||
</Text>
|
||||
<RequestedCargoChips lines={requestedLines} size="sm" />
|
||||
</Group>
|
||||
</>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressStat({
|
||||
color,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={2} align="center">
|
||||
<Text fw={700} fz={18} c="edr-text">
|
||||
{value}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text fz="11px" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,9 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -37,9 +34,11 @@ import {
|
||||
|
||||
import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert";
|
||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
||||
import type { KpiItem } from "@/components/page";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
@@ -196,6 +195,29 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
const workflowFiles = clearance.workflowFiles ?? [];
|
||||
|
||||
const direction = contract?.tradeDirection ?? "—";
|
||||
const customs =
|
||||
contract?.serviceType?.includesCustoms ??
|
||||
contract?.customsClearingEnabled ??
|
||||
false;
|
||||
const routes = [...(contract?.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const origin =
|
||||
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
|
||||
const lastRoute = routes[routes.length - 1] ?? routes[0];
|
||||
const destination =
|
||||
lastRoute?.destinationYard?.label ??
|
||||
lastRoute?.destinationYard?.code ??
|
||||
"Destination";
|
||||
|
||||
const kpis: KpiItem[] = [
|
||||
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
|
||||
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
|
||||
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
|
||||
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
@@ -206,57 +228,81 @@ export default function ContractClearanceDetailPage() {
|
||||
{ label: hubLabel, href: hubHref },
|
||||
{ label: reference },
|
||||
]}
|
||||
subtitle={
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{id ? (
|
||||
<EntityLink to={`/dashboard/contract-requests/${id}`} label="Contract details" />
|
||||
) : null}
|
||||
<Text size="sm" c="dimmed">
|
||||
· {origin}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
meta={
|
||||
bookingExpired ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<RefreshCw size={13} />}
|
||||
>
|
||||
Payment expired — rebook
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
|
||||
{directionLabel(direction)}
|
||||
</Badge>
|
||||
) : bookingAlreadyCreated ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Booking created
|
||||
</Badge>
|
||||
) : ready ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Ready — create booking
|
||||
</Badge>
|
||||
) : clearance.allApproved ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
>
|
||||
All approved
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<Clock size={13} />}
|
||||
>
|
||||
Review pending
|
||||
</Badge>
|
||||
)
|
||||
{customs ? (
|
||||
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
|
||||
Customs
|
||||
</Badge>
|
||||
) : null}
|
||||
{bookingExpired ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<RefreshCw size={13} />}
|
||||
>
|
||||
Payment expired — rebook
|
||||
</Badge>
|
||||
) : bookingAlreadyCreated ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Booking created
|
||||
</Badge>
|
||||
) : ready ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Ready — create booking
|
||||
</Badge>
|
||||
) : clearance.allApproved ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
>
|
||||
All approved
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<Clock size={13} />}
|
||||
>
|
||||
Review pending
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
<KpiStrip items={kpis} />
|
||||
|
||||
{/* Windows on this contract's routes/direction only — tells GL ET when
|
||||
it can actually create the booking without checking the schedule board. */}
|
||||
@@ -383,6 +429,7 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<Stack gap="md">
|
||||
<RequestCustomerCard contract={contract} />
|
||||
{phasedCustoms ? (
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={id!}
|
||||
@@ -425,23 +472,6 @@ export default function ContractClearanceDetailPage() {
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
@@ -457,126 +487,3 @@ export default function ContractClearanceDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceHero({
|
||||
contract,
|
||||
stats,
|
||||
}: {
|
||||
contract: ReturnType<typeof useContractDetail>["data"];
|
||||
stats: { pct: number; approved: number; total: number };
|
||||
}) {
|
||||
const direction = contract?.tradeDirection ?? "—";
|
||||
const serviceName = contract?.serviceType?.serviceName ?? null;
|
||||
const customs =
|
||||
contract?.serviceType?.includesCustoms ??
|
||||
contract?.customsClearingEnabled ??
|
||||
false;
|
||||
const routes = [...(contract?.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const origin =
|
||||
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
|
||||
const last = routes[routes.length - 1] ?? routes[0];
|
||||
const destination =
|
||||
last?.destinationYard?.label ??
|
||||
last?.destinationYard?.code ??
|
||||
"Destination";
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
||||
<ShieldCheck size={26} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={800} fz={20} c="edr-text" truncate>
|
||||
{contract?.reference ?? "Clearance"}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={direction === "IMPORT" ? "edr-green" : "gray"}
|
||||
radius="sm"
|
||||
>
|
||||
{directionLabel(direction)}
|
||||
</Badge>
|
||||
{customs ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={12} />}
|
||||
>
|
||||
Customs
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||
No customs
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{serviceName && (
|
||||
<Text size="sm" fw={600} c="edr-text" mt={6} truncate maw={280}>
|
||||
{serviceName}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap={8} mt={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Document review
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stats.approved}/{stats.total}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressStat({
|
||||
color,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={2} align="center">
|
||||
<Text fw={700} fz={18} c="edr-text">
|
||||
{value}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text fz="11px" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,9 +29,10 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards";
|
||||
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
|
||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
@@ -56,6 +58,7 @@ type GlClearanceDetail =
|
||||
reference: string;
|
||||
tradeDirection: string;
|
||||
clearance: Freight.ContractClearanceView;
|
||||
contract: Freight.IContract;
|
||||
}
|
||||
| {
|
||||
kind: "booking";
|
||||
@@ -79,6 +82,7 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
reference: contract.reference,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
clearance,
|
||||
contract,
|
||||
};
|
||||
} catch {
|
||||
const [clearance, booking] = await Promise.all([
|
||||
@@ -181,6 +185,16 @@ export default function GlClearanceDetailPage() {
|
||||
{ label: "GL Djibouti Clearance", href: backTo },
|
||||
{ label: data.reference },
|
||||
]}
|
||||
subtitle={
|
||||
<EntityLink
|
||||
to={
|
||||
data.kind === "contract"
|
||||
? `/dashboard/contract-requests/${id}`
|
||||
: `/dashboard/booking-requests/${id}`
|
||||
}
|
||||
label={data.kind === "contract" ? "Contract details" : "Booking details"}
|
||||
/>
|
||||
}
|
||||
meta={
|
||||
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
|
||||
{directionLabel(data.tradeDirection)}
|
||||
@@ -278,37 +292,44 @@ export default function GlClearanceDetailPage() {
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={data.kind === "contract" ? id : undefined}
|
||||
bookingId={data.kind === "booking" ? id : linkedBookingId}
|
||||
// For a per-booking instance, "created" means COMPLETED (has
|
||||
// cargo/price), not merely that a booking row exists — a bare
|
||||
// instance is not yet a real booking. Contract-level clearance
|
||||
// keeps its linked-booking signal.
|
||||
bookingCreated={
|
||||
data.kind === "booking"
|
||||
? bookingCompleted
|
||||
: Boolean(linkedBookingId)
|
||||
}
|
||||
bookingMilestones={
|
||||
data.kind === "booking"
|
||||
? (data.clearance.milestones ?? [])
|
||||
: (bookingMilestones ?? [])
|
||||
}
|
||||
clearance={data.clearance}
|
||||
tradeDirection={data.tradeDirection}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="DJ"
|
||||
useUploadModals
|
||||
onUploadDoRequest={() => setUploadKind("do")}
|
||||
onUploadRoRequest={() => setUploadKind("ro")}
|
||||
onChanged={() => {
|
||||
void refetch();
|
||||
refetchBookingMilestonesIfLinked();
|
||||
}}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
<Stack gap="md">
|
||||
{data.kind === "contract" ? (
|
||||
<RequestCustomerCard contract={data.contract} />
|
||||
) : (
|
||||
<BookingCompanyCard booking={data.booking} />
|
||||
)}
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={data.kind === "contract" ? id : undefined}
|
||||
bookingId={data.kind === "booking" ? id : linkedBookingId}
|
||||
// For a per-booking instance, "created" means COMPLETED (has
|
||||
// cargo/price), not merely that a booking row exists — a bare
|
||||
// instance is not yet a real booking. Contract-level clearance
|
||||
// keeps its linked-booking signal.
|
||||
bookingCreated={
|
||||
data.kind === "booking"
|
||||
? bookingCompleted
|
||||
: Boolean(linkedBookingId)
|
||||
}
|
||||
bookingMilestones={
|
||||
data.kind === "booking"
|
||||
? (data.clearance.milestones ?? [])
|
||||
: (bookingMilestones ?? [])
|
||||
}
|
||||
clearance={data.clearance}
|
||||
tradeDirection={data.tradeDirection}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="DJ"
|
||||
useUploadModals
|
||||
onUploadDoRequest={() => setUploadKind("do")}
|
||||
onUploadRoRequest={() => setUploadKind("ro")}
|
||||
onChanged={() => {
|
||||
void refetch();
|
||||
refetchBookingMilestonesIfLinked();
|
||||
}}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { Freight } from "@edr/types";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import {
|
||||
RequestCustomerCard,
|
||||
RequestContractSummaryCard,
|
||||
@@ -113,7 +114,17 @@ export default function ShipmentRequestDetailPage() {
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={`Shipment request ${request.reference}`}
|
||||
subtitle={`On contract ${contractRef}`}
|
||||
subtitle={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
On contract
|
||||
</Text>
|
||||
<EntityLink
|
||||
to={`/dashboard/contract-requests/${request.contractId}`}
|
||||
label={contractRef}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
backTo="/dashboard/shipment-requests"
|
||||
breadcrumbs={[
|
||||
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" },
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Contact,
|
||||
Download,
|
||||
Eye,
|
||||
FileSignature,
|
||||
FileText,
|
||||
History,
|
||||
Hourglass,
|
||||
@@ -55,7 +56,6 @@ import {
|
||||
ProfileStatusBadge,
|
||||
ProfileTypeBadge,
|
||||
RequestDocumentChangeModal,
|
||||
ResetPasswordAction,
|
||||
TableCard,
|
||||
formatBytes,
|
||||
formatDate,
|
||||
@@ -63,6 +63,8 @@ import {
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import { useContractList } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
@@ -85,6 +87,7 @@ import {
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
|
||||
function downloadTinRecord(company: Company) {
|
||||
@@ -170,6 +173,7 @@ export default function CustomerDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const contractsQuery = useContractList({ companyId: id, pageSize: 100 }, Boolean(id));
|
||||
|
||||
const { pagination: invoicePagination, setPagination: setInvoicePagination } =
|
||||
usePagination({
|
||||
@@ -191,6 +195,7 @@ export default function CustomerDetailPage() {
|
||||
);
|
||||
|
||||
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
|
||||
const contracts = contractsQuery.data?.items ?? [];
|
||||
const documents = Array.isArray(documentsQuery.data)
|
||||
? documentsQuery.data
|
||||
: [];
|
||||
@@ -402,6 +407,59 @@ export default function CustomerDetailPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const contractColumns: ColumnDef<Freight.IContract>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "reference",
|
||||
header: "Contract",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: "Kind",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<ContractStatusBadge
|
||||
status={row.original.status}
|
||||
isRenewal={Boolean(row.original.renewalOfId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "validUntil",
|
||||
header: "Valid until",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(row.original.contractValidUntil)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: "Created",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const documentColumns: ColumnDef<CustomerDocument>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -709,7 +767,6 @@ export default function CustomerDetailPage() {
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
action={<ResetPasswordAction company={company} />}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
@@ -720,6 +777,9 @@ export default function CustomerDetailPage() {
|
||||
<Tabs.Tab value="bookings" leftSection={<Package size={16} />}>
|
||||
Bookings
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="contracts" leftSection={<FileSignature size={16} />}>
|
||||
Contracts
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="documents" leftSection={<FileText size={16} />}>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
@@ -1187,6 +1247,7 @@ export default function CustomerDetailPage() {
|
||||
status={tableStatus(bookingsQuery)}
|
||||
emptyMessage="No bookings for this customer."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
onRowClick={(row) => navigate(`/dashboard/booking-requests/${row.id}`)}
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
@@ -1199,6 +1260,28 @@ export default function CustomerDetailPage() {
|
||||
</TableCard>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* CONTRACTS */}
|
||||
<Tabs.Panel value="contracts" pt="lg">
|
||||
<TableCard minWidth={860}>
|
||||
<DataTable
|
||||
columns={contractColumns}
|
||||
data={contracts}
|
||||
status={tableStatus(contractsQuery)}
|
||||
emptyMessage="No contracts for this customer."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
onRowClick={(row) => navigate(`/dashboard/contract-requests/${row.id}`)}
|
||||
error={
|
||||
contractsQuery.isError
|
||||
? {
|
||||
message: "Failed to load contracts.",
|
||||
onRetry: () => void contractsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCard>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* DOCUMENTS */}
|
||||
<Tabs.Panel value="documents" pt="lg">
|
||||
<Stack gap="lg">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
@@ -12,7 +14,7 @@ import {
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Download } from "lucide-react";
|
||||
import { ArrowLeft, Building2, Download, FileText } from "lucide-react";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
|
||||
@@ -26,8 +28,11 @@ import {
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { LinkedEntityCard, type FieldRowProps } from "@/components/detail";
|
||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||
import { api } from "@/services/api";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
|
||||
function openPdfBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -43,7 +48,17 @@ function openPdfBlob(blob: Blob, filename: string) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||
function InfoField({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
}) {
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === "string" && !value.trim());
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text
|
||||
@@ -56,12 +71,75 @@ function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" c="edr-text">
|
||||
{value && value.trim() ? value : "—"}
|
||||
{isEmpty ? "—" : value}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Billed-to company, with its contact/registration details as quick-info rows. */
|
||||
function RecipientCard({ invoice }: { invoice: Invoice }) {
|
||||
const company = invoice.company;
|
||||
const rows: FieldRowProps[] = [
|
||||
{ label: "Profile", value: invoice.companyProfile?.reference },
|
||||
{ label: "TIN", value: company?.tin },
|
||||
{ label: "VAT No.", value: company?.vatNumber },
|
||||
{ label: "Phone", value: company?.phone },
|
||||
{ label: "Email", value: company?.email },
|
||||
{ label: "Address", value: company?.address },
|
||||
];
|
||||
return (
|
||||
<LinkedEntityCard
|
||||
icon={Building2}
|
||||
title="Recipient"
|
||||
name={company?.name ?? "Unnamed company"}
|
||||
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
|
||||
rows={rows}
|
||||
emptyMessage="No additional recipient details available."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the invoice was raised for — a booking's route/wagons when the
|
||||
* source is a booking; otherwise just the source type and its raw id
|
||||
* (warehouse/demurrage/first-mile/last-mile ids don't link anywhere). */
|
||||
function SourceCard({ invoice }: { invoice: Invoice }) {
|
||||
const isBooking = invoice.source === "booking";
|
||||
const { data: booking } = useBookingDetail(
|
||||
isBooking ? invoice.sourceId : undefined,
|
||||
);
|
||||
|
||||
if (!isBooking) {
|
||||
return (
|
||||
<LinkedEntityCard
|
||||
icon={FileText}
|
||||
title="Source"
|
||||
name={humanize(invoice.source)}
|
||||
rows={[{ label: "Reference", value: invoice.sourceId }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const route =
|
||||
booking?.originYard && booking?.destinationYard
|
||||
? `${booking.originYard.label} → ${booking.destinationYard.label}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<LinkedEntityCard
|
||||
icon={FileText}
|
||||
title="Source"
|
||||
name={booking?.reference ?? invoice.sourceId}
|
||||
to={`/dashboard/booking-requests/${invoice.sourceId}`}
|
||||
rows={[
|
||||
{ label: "Type", value: humanize(invoice.type) },
|
||||
{ label: "Route", value: route },
|
||||
{ label: "Wagons", value: booking?.wagonsRequired ?? undefined },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvoiceDetailPage() {
|
||||
const { user } = useAuth();
|
||||
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
|
||||
@@ -121,7 +199,7 @@ export default function InvoiceDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/invoices"
|
||||
title={invoice.invoiceNumber}
|
||||
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`}
|
||||
subtitle={humanize(invoice.source)}
|
||||
meta={<InvoiceStatusBadge status={invoice.status} />}
|
||||
action={
|
||||
<ActionIcon
|
||||
@@ -138,125 +216,130 @@ export default function InvoiceDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
<Card>
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} c="edr-text">
|
||||
Summary
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
<InfoField label="Billed to" value={invoice.company?.name} />
|
||||
<InfoField
|
||||
label="Profile"
|
||||
value={invoice.companyProfile?.reference}
|
||||
/>
|
||||
<InfoField label="Type" value={humanize(invoice.type)} />
|
||||
<InfoField label="Currency" value={invoice.currency} />
|
||||
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
|
||||
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
|
||||
<InfoField
|
||||
label="Total"
|
||||
value={formatMoney(invoice.totalAmount, invoice.currency)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Balance"
|
||||
value={formatMoney(invoice.balanceAmount, invoice.currency)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} c="edr-text">
|
||||
Amounts
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
<InfoField label="Currency" value={invoice.currency} />
|
||||
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
|
||||
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
|
||||
<InfoField
|
||||
label="Total"
|
||||
value={formatMoney(invoice.totalAmount, invoice.currency)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Balance"
|
||||
value={formatMoney(invoice.balanceAmount, invoice.currency)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<EimsFilingCard invoiceId={invoice.id} />
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} c="edr-text">
|
||||
Line items
|
||||
</Text>
|
||||
<Table striped withRowBorders={false} verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Charge type</Table.Th>
|
||||
<Table.Th ta="right">Quantity</Table.Th>
|
||||
<Table.Th ta="right">Unit rate</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(invoice.lines ?? []).map((line) => (
|
||||
<Table.Tr key={line.id}>
|
||||
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{humanize(line.chargeType)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{line.quantity}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{formatMoney(line.unitRate, line.currency)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{formatMoney(line.amount, line.currency)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{(invoice.lines ?? []).length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No line items.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap="xl"
|
||||
pt="sm"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Subtotal
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.subtotalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Tax
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.taxAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Paid
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.paidAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" fw={700}>
|
||||
Total
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{formatMoney(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
<EimsFilingCard invoiceId={invoice.id} />
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} c="edr-text">
|
||||
Line items
|
||||
</Text>
|
||||
<Table striped withRowBorders={false} verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Charge type</Table.Th>
|
||||
<Table.Th ta="right">Quantity</Table.Th>
|
||||
<Table.Th ta="right">Unit rate</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(invoice.lines ?? []).map((line) => (
|
||||
<Table.Tr key={line.id}>
|
||||
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{humanize(line.chargeType)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{line.quantity}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{formatMoney(line.unitRate, line.currency)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{formatMoney(line.amount, line.currency)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{(invoice.lines ?? []).length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No line items.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap="xl"
|
||||
pt="sm"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Subtotal
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.subtotalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Tax
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.taxAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Paid
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.paidAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" fw={700}>
|
||||
Total
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{formatMoney(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="lg">
|
||||
<RecipientCard invoice={invoice} />
|
||||
<SourceCard invoice={invoice} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
RingProgress,
|
||||
@@ -19,7 +21,6 @@ import {
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
FileText,
|
||||
History as HistoryIcon,
|
||||
LayoutGrid,
|
||||
MoreHorizontal,
|
||||
Navigation,
|
||||
Package,
|
||||
PackageCheck,
|
||||
@@ -42,7 +44,7 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { KpiStrip, PageContainer } from "@/components/page";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
@@ -886,284 +888,248 @@ export default function TrainScheduleV2DetailPage() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/dashboard/operations/train-scheduling-v2"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
w="fit-content"
|
||||
>
|
||||
Back to schedules
|
||||
</Button>
|
||||
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{ position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={56} radius="lg" variant="light" color="#F2A516">
|
||||
<Train size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
{schedule.reference ? (
|
||||
<Badge
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
style={{ fontWeight: 700, fontFamily: "monospace" }}
|
||||
>
|
||||
{schedule.reference}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
{schedule.route?.name ?? "Train schedule"}
|
||||
</Title>
|
||||
{schedule.train?.trainName ? (
|
||||
<Text fw={700} style={{ color: "#0f172a" }}>
|
||||
{schedule.train.trainName}
|
||||
</Text>
|
||||
) : null}
|
||||
{schedule.train ? (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
Train {schedule.train.code}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* Voyage (train) number and trade direction — the two things
|
||||
operations identify a run by, so they read at a glance
|
||||
rather than as small badges among the rest. */}
|
||||
<Group gap="lg" align="center" wrap="wrap">
|
||||
{schedule.trainNumber ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Train No.
|
||||
</Text>
|
||||
<Text
|
||||
ff="monospace"
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
style={{ fontSize: 32, color: "#0f172a" }}
|
||||
>
|
||||
{schedule.trainNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{schedule.voyageNumber ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Voyage No.
|
||||
</Text>
|
||||
<Text
|
||||
ff="monospace"
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
style={{ fontSize: 32, color: "#0f172a" }}
|
||||
>
|
||||
{schedule.voyageNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{/* Merging rewrites the consist, so it is offered only while
|
||||
the departure can still be edited. */}
|
||||
{canEditBookings ? (
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<Merge size={14} />}
|
||||
onClick={() => setMergeModalOpen(true)}
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
) : null}
|
||||
{schedule.direction ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Direction
|
||||
</Text>
|
||||
<Text
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
tt="uppercase"
|
||||
style={{
|
||||
fontSize: 32,
|
||||
letterSpacing: 0.5,
|
||||
color:
|
||||
schedule.direction === "IMPORT"
|
||||
? "#2E5B96"
|
||||
: schedule.direction === "EXPORT"
|
||||
? "#0A6F4D"
|
||||
: "#0f172a",
|
||||
}}
|
||||
>
|
||||
{schedule.direction}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Group>
|
||||
{(schedule.stops?.length ?? 0) >= 3 ||
|
||||
(schedule.bookings ?? []).some(
|
||||
(b) => b.tradeDirection === "DOMESTIC",
|
||||
) ? (
|
||||
<SegmentOccupancyStrip
|
||||
stops={schedule.stops ?? []}
|
||||
bookings={schedule.bookings ?? []}
|
||||
maxWagons={schedule.maxWagons}
|
||||
maxGrossTons={schedule.maxGrossWeightTons}
|
||||
<PageHeader
|
||||
title={schedule.route?.name ?? "Train schedule"}
|
||||
backTo="/dashboard/operations/train-scheduling-v2"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Train schedules",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
},
|
||||
{ label: schedule.reference ?? "Schedule" },
|
||||
]}
|
||||
subtitle={
|
||||
schedule.train ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{schedule.train.trainName ?? `Train ${schedule.train.code}`}
|
||||
{schedule.train.trainName ? ` · Train ${schedule.train.code}` : ""}
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
meta={
|
||||
<Group gap={6} wrap="wrap">
|
||||
{schedule.reference ? (
|
||||
<Badge
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
style={{ fontWeight: 700, fontFamily: "monospace" }}
|
||||
>
|
||||
{schedule.reference}
|
||||
</Badge>
|
||||
) : null}
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
<StatusPill status={schedule.status} />
|
||||
{gatepassApplies && gatepassSecured ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<CheckCircle2 size={12} />}
|
||||
>
|
||||
Gate pass secured
|
||||
</Badge>
|
||||
) : null}
|
||||
{previewResult ? (
|
||||
<Badge
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={previewResult.valid ? "edr-green" : "red"}
|
||||
leftSection={
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: previewResult.valid
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: "var(--mantine-color-red-6)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box maw={340}>
|
||||
<RouteCorridor
|
||||
origin={
|
||||
schedule.originStation?.label ?? schedule.originStation?.code
|
||||
}
|
||||
destination={
|
||||
schedule.destinationStation?.label ??
|
||||
schedule.destinationStation?.code
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Group gap="sm" align="center">
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
<StatusPill status={schedule.status} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: "#0f172a", to: "#334155" }}
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<Eye size={16} />}
|
||||
onClick={() => setVisualization3DOpen(true)}
|
||||
>
|
||||
3D Visualization
|
||||
</Button>
|
||||
) : null}
|
||||
{canPrintMarshalling ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={downloadMarshalling.isPending}
|
||||
onClick={() => void openMarshallingDocument()}
|
||||
>
|
||||
Marshalling PDF
|
||||
</Button>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={downloadMarshalling.isPending}
|
||||
onClick={() =>
|
||||
void openMarshallingDocument({
|
||||
title: "Intercity marshalling ready",
|
||||
variant: "INTERCITY",
|
||||
})
|
||||
}
|
||||
>
|
||||
Intercity Marshalling
|
||||
</Button>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<Navigation size={16} />}
|
||||
>
|
||||
Track train
|
||||
</Button>
|
||||
) : null}
|
||||
{schedule.windowPhase === "PRE_WINDOW" ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<Clock size={16} />}
|
||||
onClick={() => setWindowSettingsOpen(true)}
|
||||
>
|
||||
Window settings
|
||||
</Button>
|
||||
) : null}
|
||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
onClick={() => setMaintenanceOpen(true)}
|
||||
>
|
||||
Reschedule train
|
||||
</Button>
|
||||
) : null}
|
||||
{gatepassApplies ? (
|
||||
gatepassSecured ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
disabled
|
||||
}
|
||||
>
|
||||
Preview {previewResult.valid ? "valid" : "has issues"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
action={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{/* Merging rewrites the consist, so it is offered only while
|
||||
the departure can still be edited. */}
|
||||
{canEditBookings ? (
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<Merge size={14} />}
|
||||
onClick={() => setMergeModalOpen(true)}
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
) : null}
|
||||
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
|
||||
<Button
|
||||
variant="gradient"
|
||||
gradient={{ from: "#0f172a", to: "#334155" }}
|
||||
radius="lg"
|
||||
size="compact-sm"
|
||||
leftSection={<Eye size={16} />}
|
||||
onClick={() => setVisualization3DOpen(true)}
|
||||
>
|
||||
3D Visualization
|
||||
</Button>
|
||||
) : null}
|
||||
<Menu position="bottom-end" width={240} withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" size="lg" radius="md" aria-label="More actions">
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{canPrintMarshalling ? (
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={15} />}
|
||||
disabled={downloadMarshalling.isPending}
|
||||
onClick={() => void openMarshallingDocument()}
|
||||
>
|
||||
Gate pass secured
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={secureGatepass.isPending}
|
||||
Marshalling PDF
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={15} />}
|
||||
disabled={downloadMarshalling.isPending}
|
||||
onClick={() =>
|
||||
void openMarshallingDocument({
|
||||
title: "Intercity marshalling ready",
|
||||
variant: "INTERCITY",
|
||||
})
|
||||
}
|
||||
>
|
||||
Intercity Marshalling
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
|
||||
leftSection={<Navigation size={15} />}
|
||||
>
|
||||
Track train
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{schedule.windowPhase === "PRE_WINDOW" ? (
|
||||
<Menu.Item
|
||||
leftSection={<Clock size={15} />}
|
||||
onClick={() => setWindowSettingsOpen(true)}
|
||||
>
|
||||
Window settings
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
|
||||
Reschedule train
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{gatepassApplies && !gatepassSecured ? (
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={15} />}
|
||||
disabled={secureGatepass.isPending}
|
||||
onClick={() => secureGatepass.mutate()}
|
||||
>
|
||||
Secure gate pass
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
{previewResult ? (
|
||||
<Badge
|
||||
size="lg"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={previewResult.valid ? "edr-green" : "red"}
|
||||
leftSection={
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
{/* Ops signage: Train No. / Voyage No. / Direction read at a glance from
|
||||
across the room, so these stay large rather than folding into the
|
||||
numeric KpiStrip below. */}
|
||||
<Paper radius="xl" p="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="lg" align="center" wrap="wrap">
|
||||
{schedule.trainNumber ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Train No.
|
||||
</Text>
|
||||
<Text
|
||||
ff="monospace"
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
style={{ fontSize: 32, color: "#0f172a" }}
|
||||
>
|
||||
{schedule.trainNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{schedule.voyageNumber ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Voyage No.
|
||||
</Text>
|
||||
<Text
|
||||
ff="monospace"
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
style={{ fontSize: 32, color: "#0f172a" }}
|
||||
>
|
||||
{schedule.voyageNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{schedule.direction ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Direction
|
||||
</Text>
|
||||
<Text
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
tt="uppercase"
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: previewResult.valid
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: "var(--mantine-color-red-6)",
|
||||
fontSize: 32,
|
||||
letterSpacing: 0.5,
|
||||
color:
|
||||
schedule.direction === "IMPORT"
|
||||
? "#2E5B96"
|
||||
: schedule.direction === "EXPORT"
|
||||
? "#0A6F4D"
|
||||
: "#0f172a",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Preview {previewResult.valid ? "valid" : "has issues"}
|
||||
</Badge>
|
||||
) : null}
|
||||
>
|
||||
{schedule.direction}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Group>
|
||||
{(schedule.stops?.length ?? 0) >= 3 ||
|
||||
(schedule.bookings ?? []).some(
|
||||
(b) => b.tradeDirection === "DOMESTIC",
|
||||
) ? (
|
||||
<SegmentOccupancyStrip
|
||||
stops={schedule.stops ?? []}
|
||||
bookings={schedule.bookings ?? []}
|
||||
maxWagons={schedule.maxWagons}
|
||||
maxGrossTons={schedule.maxGrossWeightTons}
|
||||
/>
|
||||
) : (
|
||||
<Box maw={340}>
|
||||
<RouteCorridor
|
||||
origin={
|
||||
schedule.originStation?.label ?? schedule.originStation?.code
|
||||
}
|
||||
destination={
|
||||
schedule.destinationStation?.label ??
|
||||
schedule.destinationStation?.code
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface BookingListFilter {
|
||||
tab?: string;
|
||||
// customerId?: string;
|
||||
companyId?: string;
|
||||
/** Bookings drawn down under this contract (contract detail's Shipments tab). */
|
||||
contractId?: string;
|
||||
freightType?: string;
|
||||
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
|
||||
bookingType?: string;
|
||||
@@ -169,6 +171,7 @@ export const bookingsService = {
|
||||
if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses;
|
||||
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.contractId) params.contractId = filter.contractId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
|
||||
Reference in New Issue
Block a user