style: inter module integration

This commit is contained in:
Nathnael
2026-08-13 06:54:54 +00:00
parent e0f18f17b3
commit d261d6ea7c
35 changed files with 2104 additions and 2037 deletions

View File

@@ -413,6 +413,32 @@ export class BillingService {
return `data:image/png;base64,${signedQr}`; 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. */ /** Map a global invoice (+ lines) onto the source-agnostic document model. */
private async toDocumentModel( private async toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] }, invoice: Invoice & { lines: InvoiceLine[] },
@@ -446,6 +472,7 @@ export class BillingService {
{ label: "Status", value: invoice.status }, { label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type }, { label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId }, { label: "Reference", value: invoice.sourceId },
...(await this.bookingSummaryRows(invoice)),
{ label: "Currency", value: invoice.currency }, { label: "Currency", value: invoice.currency },
{ {
label: "Issued", label: "Issued",

View File

@@ -59,6 +59,7 @@ export interface BookingListFilterOptions {
assignedToSchedule?: 'true' | 'false'; assignedToSchedule?: 'true' | 'false';
companyId?: string; companyId?: string;
companyProfileId?: string; companyProfileId?: string;
contractId?: string;
contractType?: string; contractType?: string;
serviceTypeId?: string; serviceTypeId?: string;
cargoTypeId?: string; cargoTypeId?: string;
@@ -936,6 +937,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
companyProfileId: options.companyProfileId, companyProfileId: options.companyProfileId,
}); });
} }
if (options.contractId) {
qb.andWhere('booking.contract_id = :contractId', {
contractId: options.contractId,
});
}
if (options.contractType) { if (options.contractType) {
qb.andWhere('booking.contract_type = :contractType', { qb.andWhere('booking.contract_type = :contractType', {
contractType: options.contractType, contractType: options.contractType,

View File

@@ -1805,6 +1805,7 @@ export class BookingsService {
// ANDs both, so cross-company access is impossible. // ANDs both, so cross-company access is impossible.
companyId: forceCompanyId ?? filter.companyId, companyId: forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
contractId: filter.contractId,
tradeDirections, tradeDirections,
contractType: filter.contractType, contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId, serviceTypeId: filter.serviceTypeId,

View File

@@ -47,6 +47,11 @@ export class FilterBookingDto {
@IsUUID() @IsUUID()
companyProfileId?: string; companyProfileId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Filter bookings drawn down under this contract' })
@IsOptional()
@IsUUID()
contractId?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
contractType?: string; contractType?: string;

View File

@@ -1,44 +1,16 @@
import type { LucideIcon } from "lucide-react"; import { Building2, FileCheck, Mail, MapPin, Phone, User } from "lucide-react";
import { import { Text } from "@mantine/core";
Building2,
FileCheck,
Mail,
MapPin,
Phone,
User,
} from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { LinkedEntityCard } from "@/components/detail";
import type { FieldRowProps } from "@/components/detail";
import { SectionCard } from "./SectionCard"; 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 { export interface BookingCompanyCardProps {
booking: BookingDetail; 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) { export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
const company = booking.company; const company = booking.company;
@@ -46,11 +18,9 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
if (!company && booking.isGovernment) { if (!company && booking.isGovernment) {
return ( return (
<SectionCard icon={Building2} title="Customer" accent="blue"> <SectionCard icon={Building2} title="Customer" accent="blue">
<InfoRow <Text size="sm" fw={600}>
icon={Building2} {booking.governmentInstitution ?? "Government"}
label="Government" </Text>
value={booking.governmentInstitution}
/>
</SectionCard> </SectionCard>
); );
} }
@@ -67,36 +37,24 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
const companyName = company.companyName ?? company.name ?? company.label; const companyName = company.companyName ?? company.name ?? company.label;
const rows: InfoRowProps[] = [ const rows: FieldRowProps[] = [
{ icon: FileCheck, label: "TIN", value: company.tin }, { icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email }, { icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone }, { icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address }, { icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact person", value: company.contactPersonName }, { icon: User, label: "Contact person", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone }, { icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
].filter((r) => r.value); ];
return ( return (
<SectionCard <LinkedEntityCard
icon={Building2} icon={Building2}
title="Customer" title="Customer"
subtitle={companyName} name={companyName ?? "Unnamed company"}
to={company.id ? `/dashboard/customers/${company.id}` : null}
accent="blue" accent="blue"
> rows={rows}
<Stack gap={0}> emptyMessage="No additional company details available."
{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>
); );
} }

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { Truck } from "lucide-react"; import { Truck } from "lucide-react";
import { SimpleGrid } from "@mantine/core"; import { SimpleGrid, Stack } from "@mantine/core";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
@@ -8,16 +9,29 @@ import { MetricTile } from "./MetricTile";
export interface BookingMileServicesCardProps { export interface BookingMileServicesCardProps {
booking: BookingDetail; 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. */ /** First / last mile addresses, plus the export handover control. Renders
export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) { * nothing when none of the three are present. */
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) { export function BookingMileServicesCard({
booking,
handoverSection,
}: BookingMileServicesCardProps) {
const hasAddresses =
Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress);
if (!hasAddresses && !handoverSection) {
return null; return null;
} }
return ( return (
<SectionCard icon={Truck} title="Mile services" accent="grape"> <SectionCard icon={Truck} title="Mile services" accent="grape">
<Stack gap="md">
{hasAddresses && (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm"> <SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{booking.firstMilePickupAddress && ( {booking.firstMilePickupAddress && (
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} /> <MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
@@ -26,6 +40,9 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} /> <MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
)} )}
</SimpleGrid> </SimpleGrid>
)}
{handoverSection}
</Stack>
</SectionCard> </SectionCard>
); );
} }

View File

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

View File

@@ -16,10 +16,9 @@ export * from "./BookingPaymentCard";
export * from "./BookingPaymentCountdownCard"; export * from "./BookingPaymentCountdownCard";
export * from "./BookingFactsCard"; export * from "./BookingFactsCard";
export * from "./BookingDocumentsCard"; export * from "./BookingDocumentsCard";
export * from "./BookingRequestHero";
export * from "./BookingRouteServiceCard"; export * from "./BookingRouteServiceCard";
export * from "./BookingMileServicesCard"; export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard"; export * from "./BookingCargoCard";
export * from "./BookingContractSummaryCard"; export * from "./BookingContractCard";
export * from "./BookingCompanyCard"; export * from "./BookingCompanyCard";
export * from "./BookingSchedulingWindowCard"; export * from "./BookingSchedulingWindowCard";

View File

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

View File

@@ -30,6 +30,7 @@ import { clearanceWorkflowFileLabel } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles"; import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
import { LinkedEntityCard } from "@/components/detail";
import { customersService } from "@/services/customers.service"; import { customersService } from "@/services/customers.service";
type ContractFile = NonNullable<Freight.IContract["files"]>[number]; type ContractFile = NonNullable<Freight.IContract["files"]>[number];
@@ -141,13 +142,12 @@ export function ContractCustomerCard({
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<SectionCard <LinkedEntityCard
icon={Building2} icon={Building2}
title="Customer" title="Customer"
subtitle={company.name} name={company.name ?? "Unnamed company"}
to={`/dashboard/customers/${company.id}`}
accent="blue" accent="blue"
>
<InfoRows
rows={[ rows={[
{ icon: FileCheck, label: "TIN", value: company.tin }, { icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Hash, label: "VAT number", value: company.vatNumber }, { icon: Hash, label: "VAT number", value: company.vatNumber },
@@ -159,7 +159,6 @@ export function ContractCustomerCard({
{ icon: Globe, label: "Website", value: company.website }, { icon: Globe, label: "Website", value: company.website },
]} ]}
/> />
</SectionCard>
<SectionCard icon={User} title="Contact person" accent="teal"> <SectionCard icon={User} title="Contact person" accent="teal">
<InfoRows <InfoRows

View File

@@ -12,56 +12,14 @@ import {
User, User,
Warehouse, Warehouse,
} from "lucide-react"; } 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 type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { LinkedEntityCard } from "@/components/detail";
type ReqContract = NonNullable<Freight.IBookingRequest["contract"]>; 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. */ /** Customer (company) on the request's contract. */
export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) { export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) {
const company = contract?.company; const company = contract?.company;
@@ -75,13 +33,12 @@ export function RequestCustomerCard({ contract }: { contract?: ReqContract | nul
); );
} }
return ( return (
<SectionCard <LinkedEntityCard
icon={Building2} icon={Building2}
title="Customer" title="Customer"
subtitle={company.name ?? undefined} name={company.name ?? "Unnamed company"}
to={company.id ? `/dashboard/customers/${company.id}` : null}
accent="blue" accent="blue"
>
<InfoRows
rows={[ rows={[
{ icon: FileCheck, label: "TIN", value: company.tin }, { icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email }, { icon: Mail, label: "Email", value: company.email },
@@ -91,7 +48,6 @@ export function RequestCustomerCard({ contract }: { contract?: ReqContract | nul
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone }, { icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
]} ]}
/> />
</SectionCard>
); );
} }
@@ -119,13 +75,12 @@ export function RequestContractSummaryCard({
}) { }) {
if (!contract) return null; if (!contract) return null;
return ( return (
<SectionCard <LinkedEntityCard
icon={FileText} icon={FileText}
title="Contract" title="Contract"
subtitle={contract.reference} name={contract.reference}
to={`/dashboard/contract-requests/${contract.id}`}
accent="grape" accent="grape"
>
<InfoRows
rows={[ rows={[
{ {
icon: FileText, icon: FileText,
@@ -155,7 +110,6 @@ export function RequestContractSummaryCard({
}, },
]} ]}
/> />
</SectionCard>
); );
} }

View File

@@ -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&apos;ll send a single-use link to this customer&apos;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&apos;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>
</>
);
}

View File

@@ -19,10 +19,6 @@ export {
RequestDocumentChangeModal, RequestDocumentChangeModal,
type RequestDocumentChangeModalProps, type RequestDocumentChangeModalProps,
} from "./RequestDocumentChangeModal"; } from "./RequestDocumentChangeModal";
export {
default as ResetPasswordAction,
type ResetPasswordActionProps,
} from "./ResetPasswordAction";
export { formatBytes, formatDate, formatMoney, humanize } from "./format"; export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export { export {
PersonCard, PersonCard,

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import Breadcrumbs, { type BreadcrumbItem } from "@/components/ui/Breadcrumbs";
export interface PageHeaderProps { export interface PageHeaderProps {
title: string; title: string;
subtitle?: string; subtitle?: ReactNode;
/** Breadcrumb trail — pass only on nested pages (details, sub-resources). */ /** Breadcrumb trail — pass only on nested pages (details, sub-resources). */
breadcrumbs?: BreadcrumbItem[]; breadcrumbs?: BreadcrumbItem[];
/** Route to return to; renders a back arrow before the title. */ /** Route to return to; renders a back arrow before the title. */

View File

@@ -14,6 +14,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react"; import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react";
import { EntityLink } from "@/components/detail";
import type { TrainScheduleDetail } from "@/types/trainScheduling"; import type { TrainScheduleDetail } from "@/types/trainScheduling";
/** /**
@@ -254,15 +255,19 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
{e.bookings.map((b) => ( {e.bookings.map((b) => (
<Table.Tr key={b.bookingId}> <Table.Tr key={b.bookingId}>
<Table.Td w="50%"> <Table.Td w="50%">
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}> <Group gap={4} wrap="nowrap" style={{ whiteSpace: "nowrap" }}>
{b.reference} <EntityLink
to={`/dashboard/booking-requests/${b.bookingId}`}
label={b.reference}
size="sm"
fw={500}
/>
{b.route ? ( {b.route ? (
<Text span size="xs" c="dimmed"> <Text span size="xs" c="dimmed">
{" "}
({b.route}) ({b.route})
</Text> </Text>
) : null} ) : null}
</Text> </Group>
</Table.Td> </Table.Td>
<Table.Td w="25%"> <Table.Td w="25%">
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">

View File

@@ -36,6 +36,7 @@ import {
import { CountdownTimer } from "@edr/ui-common"; import { CountdownTimer } from "@edr/ui-common";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { EntityLink } from "@/components/detail";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import type { import type {
@@ -601,6 +602,7 @@ export function ScheduleWorkspacePanel({
{pool.map((b) => ( {pool.map((b) => (
<BookingCard <BookingCard
key={b.id} key={b.id}
bookingId={b.id}
reference={b.reference} reference={b.reference}
customer={b.customer} customer={b.customer}
weightTons={b.weightTons} weightTons={b.weightTons}
@@ -711,6 +713,7 @@ export function ScheduleWorkspacePanel({
return ( return (
<BookingCard <BookingCard
key={b.id} key={b.id}
bookingId={b.id}
reference={ref} reference={ref}
customer={b.customer} customer={b.customer}
weightTons={b.weightTons} weightTons={b.weightTons}
@@ -985,6 +988,7 @@ function PanelColumn({
} }
function BookingCard({ function BookingCard({
bookingId,
reference, reference,
customer, customer,
weightTons, weightTons,
@@ -996,6 +1000,8 @@ function BookingCard({
leg, leg,
right, right,
}: { }: {
/** When set, the reference links to the booking's detail page. */
bookingId?: string;
reference: string; reference: string;
customer?: string | null; customer?: string | null;
weightTons?: number | null; weightTons?: number | null;
@@ -1030,9 +1036,18 @@ function BookingCard({
<Group justify="space-between" align="center" wrap="nowrap" gap="sm"> <Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Stack gap={3} style={{ minWidth: 0 }}> <Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="nowrap"> <Group gap={8} align="center" wrap="nowrap">
{bookingId ? (
<EntityLink
to={`/dashboard/booking-requests/${bookingId}`}
label={reference}
size="sm"
fw={700}
/>
) : (
<Text size="sm" fw={700} truncate> <Text size="sm" fw={700} truncate>
{reference} {reference}
</Text> </Text>
)}
{status ? <BookingStatusBadge status={status} /> : null} {status ? <BookingStatusBadge status={status} /> : null}
{intercity ? ( {intercity ? (
<Tooltip <Tooltip

View File

@@ -1,5 +1,6 @@
import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core"; import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
import { Box, Package } from "lucide-react"; import { Box, Package } from "lucide-react";
import { EntityLink } from "@/components/detail";
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling"; import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
type WagonSlot = (WagonPlanRow & { type WagonSlot = (WagonPlanRow & {
@@ -159,9 +160,12 @@ export function WagonPlanGrid({
<Card key={`${alloc.bookingId}-${index}`} padding="xs" radius="md" bg="gray.0"> <Card key={`${alloc.bookingId}-${index}`} padding="xs" radius="md" bg="gray.0">
<Stack gap={2}> <Stack gap={2}>
<Group justify="space-between" gap="xs"> <Group justify="space-between" gap="xs">
<Text size="xs" fw={500} lineClamp={1}> <EntityLink
{alloc.bookingReference ?? alloc.bookingId} to={`/dashboard/booking-requests/${alloc.bookingId}`}
</Text> label={alloc.bookingReference ?? alloc.bookingId}
size="xs"
fw={500}
/>
{label === "BULK" ? ( {label === "BULK" ? (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{alloc.allocatedWeightTons}T cargo {alloc.allocatedWeightTons}T cargo

View File

@@ -81,6 +81,8 @@ export const QUERY_KEYS = {
["contracts", "clearance-history", region ?? "ET"] as const, ["contracts", "clearance-history", region ?? "ET"] as const,
milestones: (id: string) => ["contracts", "milestones", id] as const, milestones: (id: string) => ["contracts", "milestones", id] as const,
capacity: (id: string) => ["contracts", "capacity", id] as const, capacity: (id: string) => ["contracts", "capacity", id] as const,
bookingRequests: (id: string) =>
["contracts", "booking-requests", id] as const,
bookingMilestones: (bookingId: string) => bookingMilestones: (bookingId: string) =>
["contracts", "booking-milestones", bookingId] as const, ["contracts", "booking-milestones", bookingId] as const,
bookingIncidents: (bookingId: string) => bookingIncidents: (bookingId: string) =>

View File

@@ -2,43 +2,56 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
ArrowLeft, ArrowLeft,
Container as ContainerIcon,
FileSignature, FileSignature,
FileText, FileText,
Flame,
FolderOpen, FolderOpen,
Layers, Layers,
LayoutGrid, LayoutGrid,
Milestone, Milestone,
MoreHorizontal,
Package, Package,
RefreshCw,
Truck, Truck,
Wallet,
Weight,
} from "lucide-react"; } from "lucide-react";
import { import {
Container, ActionIcon,
Stack, Box,
Grid, Button,
Center, Center,
Container,
Grid,
Group,
Loader, Loader,
Menu,
Paper,
SegmentedControl,
Stack,
Tabs, Tabs,
Text, Text,
Paper,
Button,
Box,
SegmentedControl,
} from "@mantine/core"; } from "@mantine/core";
import { PageContainer } from "@/components/page"; import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar"; import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary"; import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper"; 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 { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import { import {
detailStyles, detailStyles,
BookingRequestHero,
BookingRouteServiceCard, BookingRouteServiceCard,
BookingMileServicesCard, BookingMileServicesCard,
BookingCargoCard, BookingCargoCard,
BookingCompanyCard, BookingCompanyCard,
BookingContractSummaryCard, BookingContractCard,
BookingContainerUnitsCard, BookingContainerUnitsCard,
BookingSchedulingWindowCard, BookingSchedulingWindowCard,
BookingDocumentsPanel, BookingDocumentsPanel,
@@ -48,6 +61,7 @@ import {
import { WarehouseInfoCard } from "@/components/warehouses"; import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { import {
useBookingDetail, useBookingDetail,
@@ -133,7 +147,6 @@ export default function BookingRequestDetailPage() {
); );
} }
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status); const statusMeta = getStatusMeta(booking.status);
// Clearance review + finalize now lives solely on the Operations "Clearance // Clearance review + finalize now lives solely on the Operations "Clearance
// Documents" hub (/dashboard/contracts/clearance-documents → detail page), so // Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
@@ -159,23 +172,176 @@ export default function BookingRequestDetailPage() {
setSearchParams(next, { replace: true }); 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 ( return (
<PageContainer> <PageContainer>
<Breadcrumbs <PageHeader
items={[ breadcrumbs={[
{ label: "Booking requests", href: "/dashboard/booking-requests" }, { label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: booking.reference }, { 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"> <Stack gap="lg">
<BookingRequestHero <KpiStrip items={kpis} />
booking={booking}
customerLabel={row.customerLabel} {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
onBack={() => navigate("/dashboard/booking-requests")} <Text size="xs" c="orange.7">
onRefresh={() => refetch()} Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
isFetching={isFetching} </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 <BookingWorkflowStepper
status={booking.status} status={booking.status}
@@ -223,7 +389,7 @@ export default function BookingRequestDetailPage() {
</Tabs.List> </Tabs.List>
<Tabs.Panel value="overview"> <Tabs.Panel value="overview">
<OverviewPanel booking={booking} row={row} /> <OverviewPanel booking={booking} onRefetch={refetch} />
</Tabs.Panel> </Tabs.Panel>
{isGeneralContract && ( {isGeneralContract && (
<Tabs.Panel value="orders"> <Tabs.Panel value="orders">
@@ -247,6 +413,7 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}> <Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg"> <Stack gap="lg">
<BookingCompanyCard booking={booking} /> <BookingCompanyCard booking={booking} />
<BookingContractCard booking={booking} />
<BookingPricingSummary booking={booking} /> <BookingPricingSummary booking={booking} />
<Box id="warehouse-payments"> <Box id="warehouse-payments">
<WarehouseInfoCard <WarehouseInfoCard
@@ -265,8 +432,36 @@ export default function BookingRequestDetailPage() {
booking={booking} booking={booking}
mutations={mutations} mutations={mutations}
/> />
{booking.tradeDirection === "EXPORT" && ( </Stack>
<Paper withBorder radius="md" p="sm"> </Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}
/** The booking's primary detail cards — route, services, cargo, containers. */
function OverviewPanel({
booking,
onRefetch,
}: {
booking: BookingDetail;
onRefetch: () => void;
}) {
const row = toBookingListRow(booking);
return (
<Stack gap="lg">
<BookingRouteServiceCard
booking={booking}
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard
booking={booking}
handoverSection={
booking.tradeDirection === "EXPORT" ? (
<Stack gap={6}> <Stack gap={6}>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
How the cargo reaches the train How the cargo reaches the train
@@ -285,7 +480,7 @@ export default function BookingRequestDetailPage() {
booking.id, booking.id,
value as "DIRECT_TO_TRAIN" | "WAREHOUSE", value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
); );
await refetch(); onRefetch();
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error error instanceof Error
@@ -301,91 +496,11 @@ export default function BookingRequestDetailPage() {
: "Cargo is received at the warehouse and issued a GRN before loading."} : "Cargo is received at the warehouse and issued a GRN before loading."}
</Text> </Text>
</Stack> </Stack>
</Paper> ) : null
)}
{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>
</Grid>
</Stack>
</PageContainer>
);
}
/** The booking's primary detail cards — route, services, cargo, containers. */
function OverviewPanel({
booking,
row,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
}) {
return (
<Stack gap="lg">
<BookingRouteServiceCard
booking={booking}
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/> />
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} /> <BookingCargoCard booking={booking} />
<BookingContainerUnitsCard booking={booking} /> <BookingContainerUnitsCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
</Stack> </Stack>
); );
} }

View File

@@ -10,11 +10,9 @@ import {
Group, Group,
Loader, Loader,
Paper, Paper,
Progress,
RingProgress, RingProgress,
Stack, Stack,
Text, Text,
ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { import {
AlertCircle, AlertCircle,
@@ -29,9 +27,13 @@ import {
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs"; import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer"; import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader"; import type { KpiItem } from "@/components/page";
import { SectionCard } from "@/components/bookings/detail"; import {
SectionCard,
BookingCompanyCard,
BookingContractCard,
} from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection"; import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper"; import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel"; 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 ( return (
<PageContainer> <PageContainer>
<Stack gap="lg"> <Stack gap="lg">
@@ -182,7 +196,16 @@ export default function DocumentClearanceDetailPage() {
{ label: reference }, { label: reference },
]} ]}
meta={ meta={
clearance.allApproved ? ( <Group gap={6} wrap="wrap">
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
{direction}
</Badge>
{clearance.includesCustoms ? (
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
Customs
</Badge>
) : null}
{clearance.allApproved ? (
<Badge <Badge
variant="light" variant="light"
color="edr-green" color="edr-green"
@@ -200,7 +223,19 @@ export default function DocumentClearanceDetailPage() {
> >
Review pending Review pending
</Badge> </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={ action={
canCompleteBooking ? ( canCompleteBooking ? (
@@ -233,12 +268,16 @@ export default function DocumentClearanceDetailPage() {
} }
/> />
<ClearanceHero <KpiStrip items={kpis} />
booking={booking}
clearance={clearance} {requestedLines ? (
stats={stats} <Group gap={10} align="center" wrap="wrap">
requestedLines={requestedLines} <Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
/> Requested cargo
</Text>
<RequestedCargoChips lines={requestedLines} size="sm" />
</Group>
) : null}
{isPhasedGeneral ? ( {isPhasedGeneral ? (
<Paper withBorder radius="md" p="lg"> <Paper withBorder radius="md" p="lg">
@@ -273,6 +312,10 @@ export default function DocumentClearanceDetailPage() {
</Grid.Col> </Grid.Col>
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}> <Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
{booking ? <BookingCompanyCard booking={booking} /> : null}
{booking ? <BookingContractCard booking={booking} /> : null}
{isPhasedGeneral ? ( {isPhasedGeneral ? (
<PhasedClearanceActionPanel <PhasedClearanceActionPanel
bookingId={id!} bookingId={id!}
@@ -290,7 +333,6 @@ export default function DocumentClearanceDetailPage() {
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/> />
) : ( ) : (
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard <SectionCard
icon={PackageCheck} icon={PackageCheck}
title="Review progress" title="Review progress"
@@ -313,27 +355,11 @@ export default function DocumentClearanceDetailPage() {
</Stack> </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> </Stack>
</SectionCard> </SectionCard>
</Box>
)} )}
</Stack>
</Box>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
} }
@@ -347,125 +373,3 @@ export default function DocumentClearanceDetailPage() {
</PageContainer> </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>
);
}

View File

@@ -10,12 +10,9 @@ import {
Grid, Grid,
Group, Group,
Loader, Loader,
Paper,
Progress,
RingProgress, RingProgress,
Stack, Stack,
Text, Text,
ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { import {
AlertCircle, AlertCircle,
@@ -37,9 +34,11 @@ import {
import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert"; import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs"; import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer"; import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader"; import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection"; import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection"; import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel"; import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
@@ -196,6 +195,29 @@ export default function ContractClearanceDetailPage() {
const workflowFiles = clearance.workflowFiles ?? []; 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 ( return (
<PageContainer> <PageContainer>
<Stack gap="lg"> <Stack gap="lg">
@@ -206,8 +228,31 @@ export default function ContractClearanceDetailPage() {
{ label: hubLabel, href: hubHref }, { label: hubLabel, href: hubHref },
{ label: reference }, { 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={ meta={
bookingExpired ? ( <Group gap={6} wrap="wrap">
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
{directionLabel(direction)}
</Badge>
{customs ? (
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
Customs
</Badge>
) : null}
{bookingExpired ? (
<Badge <Badge
variant="light" variant="light"
color="orange" color="orange"
@@ -252,11 +297,12 @@ export default function ContractClearanceDetailPage() {
> >
Review pending Review pending
</Badge> </Badge>
) )}
</Group>
} }
/> />
<ClearanceHero contract={contract} stats={stats} /> <KpiStrip items={kpis} />
{/* Windows on this contract's routes/direction only — tells GL ET when {/* Windows on this contract's routes/direction only — tells GL ET when
it can actually create the booking without checking the schedule board. */} 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 }}> <Grid.Col span={{ base: 12, lg: 5 }}>
<Stack gap="md"> <Stack gap="md">
<RequestCustomerCard contract={contract} />
{phasedCustoms ? ( {phasedCustoms ? (
<PhasedClearanceActionPanel <PhasedClearanceActionPanel
contractId={id!} contractId={id!}
@@ -425,23 +472,6 @@ export default function ContractClearanceDetailPage() {
</Stack> </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> </Stack>
</SectionCard> </SectionCard>
</Box> </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>
);
}

View File

@@ -6,9 +6,8 @@ import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Box as BoxIcon, Box as BoxIcon,
Building2,
Calendar,
CalendarClock, CalendarClock,
ClipboardList,
Download, Download,
FileSignature, FileSignature,
FileText, FileText,
@@ -18,15 +17,16 @@ import {
Info, Info,
LayoutGrid, LayoutGrid,
Milestone, Milestone,
MoreHorizontal,
Package, Package,
Receipt, Receipt,
RefreshCw, RefreshCw,
Route as RouteIcon, Route as RouteIcon,
ShieldCheck,
Snowflake, Snowflake,
Users, Wallet,
} from "lucide-react"; } from "lucide-react";
import { import {
ActionIcon,
Alert, Alert,
Badge, Badge,
Box, Box,
@@ -36,21 +36,22 @@ import {
Grid, Grid,
Group, Group,
Loader, Loader,
Menu,
Paper, Paper,
SimpleGrid, SimpleGrid,
Stack, Stack,
Tabs, Tabs,
Text, Text,
Title,
} from "@mantine/core"; } from "@mantine/core";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import "@/components/overview/overview.css"; import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import { PageContainer } from "@/components/page"; import type { KpiItem } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { EntityLink } from "@/components/detail";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles"; import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
import { TableCard } from "@/components/customers";
import { import {
ContractCourtBadge, ContractCourtBadge,
ContractStatusBadge, ContractStatusBadge,
@@ -60,14 +61,18 @@ import { ContractActionsToolbar } from "@/components/contracts/ContractActionsTo
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard"; import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel"; import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { BookingRequestStatusBadge } from "@/components/contracts/BookingRequestStatusBadge";
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline"; import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
import { ContractMilestonesTimeline } from "@/components/contracts/ContractMilestonesTimeline"; import { ContractMilestonesTimeline } from "@/components/contracts/ContractMilestonesTimeline";
import { import {
ContractCustomerCard, ContractCustomerCard,
ContractDocumentsCard, ContractDocumentsCard,
} from "@/components/contracts/detail/ContractDetailTabCards"; } from "@/components/contracts/detail/ContractDetailTabCards";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { getContractStatusMeta } from "@/features/contracts/contract-status.config"; import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
import { useBookingList } from "@/hooks/bookings/useBookings";
import { import {
useContractDetail, useContractDetail,
useContractMutations, useContractMutations,
@@ -75,11 +80,14 @@ import {
import { contractsService } from "@/services/contracts.service"; import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { formatMoney } from "@/components/customers";
import { import {
downloadBookingFile, downloadBookingFile,
fetchViewableFile, fetchViewableFile,
} from "@/services/files.service"; } from "@/services/files.service";
import type { CustomerDocument } from "@/types/customer"; import type { CustomerDocument } from "@/types/customer";
import type { BookingDetail } from "@/types/booking";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
// Clearance phase — actionable (docs approve / query / finalize on the hub). // Clearance phase — actionable (docs approve / query / finalize on the hub).
@@ -147,11 +155,11 @@ export default function ContractRequestDetailPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { view, viewer } = useFileViewer(); const { view, viewer } = useFileViewer();
const requestedTab = searchParams.get("tab"); const requestedTab = searchParams.get("tab");
const setTab = (tab: string) => const setTab = (tab: string | null) =>
setSearchParams( setSearchParams(
(prev) => { (prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
if (tab === "details") next.delete("tab"); if (!tab || tab === "details") next.delete("tab");
else next.set("tab", tab); else next.set("tab", tab);
return next; return next;
}, },
@@ -212,6 +220,18 @@ export default function ContractRequestDetailPage() {
}) satisfies NonNullable<Freight.IContract["files"]>[number], }) satisfies NonNullable<Freight.IContract["files"]>[number],
); );
// Bookings drawn down under this contract, and the customer's raw shipment
// requests against it — the two halves of "what has this contract produced".
const { data: contractBookings, isLoading: bookingsLoading } = useBookingList(
{ contractId: id, pageSize: 100 },
Boolean(id),
);
const bookingRequestsQuery = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.bookingRequests(id ?? ""),
queryFn: () => contractsService.listBookingRequests(id!),
enabled: Boolean(id),
});
const downloadContractPdf = async () => { const downloadContractPdf = async () => {
if (!contract?.id) return; if (!contract?.id) return;
try { try {
@@ -313,12 +333,12 @@ export default function ContractRequestDetailPage() {
contract.status === "SIGNED_CUSTOMER") && contract.status === "SIGNED_CUSTOMER") &&
Boolean(contract.contractGeneratedAt); Boolean(contract.contractGeneratedAt);
// Resolve the active tab from the URL, falling back to details when the // Resolve the active tab from the URL, falling back to details when the
// requested tab isn't available for this contract (e.g. clearance pre-phase). // requested tab isn't available for this contract.
const currentTab = const currentTab =
requestedTab === "documents" requestedTab === "shipments"
? "shipments"
: requestedTab === "documents"
? "documents" ? "documents"
: requestedTab === "customer"
? "customer"
: requestedTab === "history" : requestedTab === "history"
? "history" ? "history"
: "details"; : "details";
@@ -327,55 +347,45 @@ export default function ContractRequestDetailPage() {
? (contract.governmentInstitution ?? "Government") ? (contract.governmentInstitution ?? "Government")
: (contract.company?.name ?? "—"); : (contract.company?.name ?? "—");
const kpis: KpiItem[] = [
{
label: "Shipments",
value: contract.activeBookingCount ?? 0,
hint: "active",
icon: Package,
color: "edr-green",
},
{
label: "Valid until",
value: contract.contractValidUntil ? formatDate(contract.contractValidUntil) : "—",
icon: CalendarClock,
color: "blue",
},
{
label: "Routes",
value: routes.length,
icon: RouteIcon,
color: "teal",
},
{
label: "Currency",
value: contract.paymentCurrency,
icon: Wallet,
color: "orange",
},
];
return ( return (
<PageContainer> <PageContainer>
<Breadcrumbs <PageHeader
items={[ breadcrumbs={[
{ label: "Contract requests", href: "/dashboard/contract-requests" }, { label: "Contract requests", href: "/dashboard/contract-requests" },
{ label: contract.reference }, { label: contract.reference },
]} ]}
/> backTo="/dashboard/contract-requests"
title={contract.reference}
<Stack gap="lg"> meta={
{/* Hero */} <Group gap={6} wrap="wrap">
<Paper radius="xl" p="xl" style={{ position: "relative", overflow: "hidden" }}>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Button
variant="default"
size="compact-sm"
radius="lg"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/contract-requests")}
>
Back to list
</Button>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
loading={isFetching}
onClick={() => refetch()}
>
Refresh
</Button>
</Group>
<Stack gap="sm">
<Text
size="xs"
fw={700}
tt="uppercase"
style={{ letterSpacing: 1, color: "#B26C09" }}
>
Contract reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{contract.reference}
</Title>
<ContractStatusBadge <ContractStatusBadge
status={contract.status} status={contract.status}
isRenewal={Boolean(contract.renewalOfId)} isRenewal={Boolean(contract.renewalOfId)}
@@ -385,40 +395,63 @@ export default function ContractRequestDetailPage() {
{contract.contractKind === "GENERAL" ? "General" : "One-time"} {contract.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge> </Badge>
</Group> </Group>
<Group gap="lg" mt={4}> }
<MetaItem icon={Building2} text={customerLabel} /> subtitle={
<MetaItem <Group gap={6} wrap="wrap">
icon={Calendar} <EntityLink
text={`Created ${formatDate(contract.createdAt)}`} to={
!contract.isGovernment && contract.companyId
? `/dashboard/customers/${contract.companyId}`
: null
}
label={customerLabel}
/> />
{contract.contractValidUntil ? ( <Text size="sm" c="dimmed">
<MetaItem · Created {formatDate(contract.createdAt)}
icon={CalendarClock} {contract.contractValidUntil
// Validity is accepted to the minute — show the time. ? ` · Valid until ${formatDateTime(contract.contractValidUntil)}`
text={`Valid until ${formatDateTime(contract.contractValidUntil)}`} : ""}
/> </Text>
) : null}
</Group> </Group>
{hasContractDocument && ( }
<Group gap="sm" mt="sm"> action={
{canViewSign && ( <Group gap="sm" wrap="nowrap">
<Button <ActionIcon
variant="light"
color="edr-green" color="edr-green"
size="compact-sm" size="lg"
radius="lg" radius="md"
loading={isFetching}
aria-label="Refresh"
onClick={() => refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
{hasContractDocument && (
<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>
{canViewSign && (
<Menu.Item
leftSection={<FileSignature size={15} />} leftSection={<FileSignature size={15} />}
onClick={() => onClick={() =>
navigate(`/dashboard/contract-requests/${contract.id}/view`) navigate(`/dashboard/contract-requests/${contract.id}/view`)
} }
> >
View &amp; sign contract View &amp; sign contract
</Button> </Menu.Item>
)} )}
{contractPdf && ( {contractPdf && (
<Button <Menu.Item
variant="default"
size="compact-sm"
radius="lg"
leftSection={<FileText size={15} />} leftSection={<FileText size={15} />}
onClick={() => onClick={() =>
void fetchViewableFile(contractPdf.id, contractPdf.name).then( void fetchViewableFile(contractPdf.id, contractPdf.name).then(
@@ -427,22 +460,23 @@ export default function ContractRequestDetailPage() {
} }
> >
View contract View contract
</Button> </Menu.Item>
)} )}
<Button <Menu.Item
variant="default"
size="compact-sm"
radius="lg"
leftSection={<Download size={15} />} leftSection={<Download size={15} />}
onClick={() => void downloadContractPdf()} onClick={() => void downloadContractPdf()}
> >
Download PDF Download PDF
</Button> </Menu.Item>
</Group> </Menu.Dropdown>
</Menu>
)} )}
</Stack> </Group>
</Stack> }
</Paper> />
<Stack gap="lg">
<KpiStrip items={kpis} />
<ContractWorkflowStepper <ContractWorkflowStepper
status={contract.status} status={contract.status}
@@ -505,17 +539,23 @@ export default function ContractRequestDetailPage() {
</Alert> </Alert>
) : null} ) : null}
<Grid gap="lg">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Tabs <Tabs
value={currentTab} value={currentTab}
onChange={(v) => setTab(v ?? "details")} onChange={setTab}
variant="pills" variant="pills"
color="edr-green" color="edr-green"
classNames={{ list: "ov-tablist", tab: "ov-tab" }} keepMounted={false}
> >
<Tabs.List> <Tabs.List mb="lg">
<Tabs.Tab value="details" leftSection={<LayoutGrid size={16} />}> <Tabs.Tab value="details" leftSection={<LayoutGrid size={16} />}>
Details Details
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="shipments" leftSection={<Package size={16} />}>
Shipments
</Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="documents" value="documents"
leftSection={<Files size={16} />} leftSection={<Files size={16} />}
@@ -529,65 +569,12 @@ export default function ContractRequestDetailPage() {
> >
Documents Documents
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
Customer
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={16} />}> <Tabs.Tab value="history" leftSection={<History size={16} />}>
History History
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
</Tabs>
<Grid gap="lg"> <Tabs.Panel value="details">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>
{currentTab === "documents" ? (
<Stack gap="lg">
<ContractDocumentsCard
files={contractDocuments}
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
<ContractDocumentsCard
files={profileDocuments}
title="Customer profile documents"
emptyText={
profileDocumentsQuery.isLoading
? "Loading customer documents…"
: "No profile documents on file for this customer."
}
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
<ClearanceWorkflowFilesPanel
files={clearanceView!.workflowFiles!}
title="Customs workflow documents"
onView={view}
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
/>
) : null}
</Stack>
) : currentTab === "history" ? (
<Stack gap="lg">
<SectionCard
icon={Milestone}
title="Key milestones"
subtitle="Submission, approval, signatures and validity — the dated record of this contract."
>
<ContractMilestonesTimeline contract={contract} />
</SectionCard>
<SectionCard
icon={History}
title="Change history"
subtitle="Every recorded edit to this contract — who changed what, and when."
>
<ContractRevisionTimeline contractId={contract.id} bare />
</SectionCard>
</Stack>
) : currentTab === "customer" ? (
<ContractCustomerCard contract={contract} />
) : (
<Stack gap="lg"> <Stack gap="lg">
<SectionCard <SectionCard
icon={Info} icon={Info}
@@ -675,14 +662,6 @@ export default function ContractRequestDetailPage() {
) : null} ) : null}
</SectionCard> </SectionCard>
<SectionCard
icon={ShieldCheck}
title="Approval & signing timeline"
subtitle="Every dated step in this contract's approval chain, plus signatures — the same record kept in the sidebar, always visible here."
>
<ContractMilestonesTimeline contract={contract} />
</SectionCard>
<SectionCard icon={RouteIcon} title="Routes"> <SectionCard icon={RouteIcon} title="Routes">
{routes.length === 0 ? ( {routes.length === 0 ? (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -855,13 +834,105 @@ export default function ContractRequestDetailPage() {
</SectionCard> </SectionCard>
) : null} ) : null}
</Stack> </Stack>
)} </Tabs.Panel>
<Tabs.Panel value="shipments">
<Stack gap="lg">
<SectionCard
icon={Package}
title="Bookings"
subtitle="Shipments drawn down under this contract."
>
<TableCard minWidth={760}>
<DataTable
columns={bookingColumns}
data={contractBookings?.items ?? []}
status={bookingsLoading ? "loading" : "success"}
emptyMessage="No bookings under this contract yet."
containerClassName="border-0 shadow-none bg-transparent"
onRowClick={(row) =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
/>
</TableCard>
</SectionCard>
<SectionCard
icon={ClipboardList}
title="Shipment requests"
subtitle="Customer-submitted requests against this contract, before they become a booking."
>
<TableCard minWidth={640}>
<DataTable
columns={requestColumns}
data={bookingRequestsQuery.data ?? []}
status={bookingRequestsQuery.isLoading ? "loading" : "success"}
emptyMessage="No shipment requests on this contract yet."
containerClassName="border-0 shadow-none bg-transparent"
onRowClick={(row) =>
navigate(`/dashboard/shipment-requests/${row.id}`)
}
/>
</TableCard>
</SectionCard>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="documents">
<Stack gap="lg">
<ContractDocumentsCard
files={contractDocuments}
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
<ContractDocumentsCard
files={profileDocuments}
title="Customer profile documents"
emptyText={
profileDocumentsQuery.isLoading
? "Loading customer documents…"
: "No profile documents on file for this customer."
}
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
<ClearanceWorkflowFilesPanel
files={clearanceView!.workflowFiles!}
title="Customs workflow documents"
onView={view}
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
/>
) : null}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="history">
<SectionCard
icon={Milestone}
title="Key milestones"
subtitle="Submission, approval, signatures and validity — the dated record of this contract."
>
<ContractMilestonesTimeline contract={contract} />
</SectionCard>
<Box mt="lg">
<SectionCard
icon={History}
title="Change history"
subtitle="Every recorded edit to this contract — who changed what, and when."
>
<ContractRevisionTimeline contractId={contract.id} bare />
</SectionCard>
</Box>
</Tabs.Panel>
</Tabs>
</Grid.Col> </Grid.Col>
{/* RIGHT — sticky action rail */} {/* RIGHT — sticky action rail */}
<Grid.Col span={{ base: 12, lg: 4 }}> <Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}> <Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg"> <Stack gap="lg">
<ContractCustomerCard contract={contract} />
<ContractActionsToolbar <ContractActionsToolbar
contract={contract} contract={contract}
mutations={mutations} mutations={mutations}
@@ -891,6 +962,97 @@ export default function ContractRequestDetailPage() {
); );
} }
const bookingColumns: ColumnDef<BookingDetail>[] = [
{
id: "reference",
header: "Booking",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
),
},
{
id: "route",
header: "Route",
cell: ({ row }) => {
const r = toBookingListRow(row.original);
return (
<Group gap={6} wrap="nowrap">
<Text size="sm" c="edr-text">
{r.originLabel}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="edr-text">
{r.destinationLabel}
</Text>
</Group>
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
},
{
id: "amount",
header: "Amount",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{formatMoney(Number(row.original.totalAmount), row.original.paymentCurrency)}
</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 requestColumns: ColumnDef<Freight.IBookingRequest>[] = [
{
id: "reference",
header: "Request",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => <BookingRequestStatusBadge status={row.original.status} />,
},
{
id: "scheduledDate",
header: "Requested for",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.scheduledDate ? formatDate(row.original.scheduledDate) : "—"}
</Text>
),
},
{
id: "createdAt",
header: "Submitted",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
];
function InfoRow({ label, value }: { label: string; value: string }) { function InfoRow({ label, value }: { label: string; value: string }) {
return ( return (
<div> <div>
@@ -909,20 +1071,3 @@ function InfoRow({ label, value }: { label: string; value: string }) {
</div> </div>
); );
} }
function MetaItem({
icon: Icon,
text,
}: {
icon: typeof Building2;
text: string;
}) {
return (
<Group gap={6} wrap="nowrap">
<Icon size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={600} c="dark">
{text}
</Text>
</Group>
);
}

View File

@@ -29,9 +29,10 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { PageContainer } from "@/components/page/PageContainer"; import { PageContainer, PageHeader } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader"; import { EntityLink } from "@/components/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection"; import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection"; import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel"; import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
@@ -42,6 +43,7 @@ import {
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel"; import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel"; import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard"; import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow"; import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
@@ -56,6 +58,7 @@ type GlClearanceDetail =
reference: string; reference: string;
tradeDirection: string; tradeDirection: string;
clearance: Freight.ContractClearanceView; clearance: Freight.ContractClearanceView;
contract: Freight.IContract;
} }
| { | {
kind: "booking"; kind: "booking";
@@ -79,6 +82,7 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
reference: contract.reference, reference: contract.reference,
tradeDirection: contract.tradeDirection, tradeDirection: contract.tradeDirection,
clearance, clearance,
contract,
}; };
} catch { } catch {
const [clearance, booking] = await Promise.all([ const [clearance, booking] = await Promise.all([
@@ -181,6 +185,16 @@ export default function GlClearanceDetailPage() {
{ label: "GL Djibouti Clearance", href: backTo }, { label: "GL Djibouti Clearance", href: backTo },
{ label: data.reference }, { 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={ meta={
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm"> <Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
{directionLabel(data.tradeDirection)} {directionLabel(data.tradeDirection)}
@@ -278,6 +292,12 @@ export default function GlClearanceDetailPage() {
</Grid.Col> </Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}> <Grid.Col span={{ base: 12, lg: 5 }}>
<Stack gap="md">
{data.kind === "contract" ? (
<RequestCustomerCard contract={data.contract} />
) : (
<BookingCompanyCard booking={data.booking} />
)}
<PhasedClearanceActionPanel <PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined} contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? id : linkedBookingId} bookingId={data.kind === "booking" ? id : linkedBookingId}
@@ -309,6 +329,7 @@ export default function GlClearanceDetailPage() {
onViewFile={view} onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/> />
</Stack>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</Tabs.Panel> </Tabs.Panel>

View File

@@ -25,6 +25,7 @@ import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer"; import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader"; import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { EntityLink } from "@/components/detail";
import { import {
RequestCustomerCard, RequestCustomerCard,
RequestContractSummaryCard, RequestContractSummaryCard,
@@ -113,7 +114,17 @@ export default function ShipmentRequestDetailPage() {
<Stack gap="lg"> <Stack gap="lg">
<PageHeader <PageHeader
title={`Shipment request ${request.reference}`} 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" backTo="/dashboard/shipment-requests"
breadcrumbs={[ breadcrumbs={[
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" }, { label: "Shipment Requests", href: "/dashboard/shipment-requests" },

View File

@@ -24,6 +24,7 @@ import {
Contact, Contact,
Download, Download,
Eye, Eye,
FileSignature,
FileText, FileText,
History, History,
Hourglass, Hourglass,
@@ -55,7 +56,6 @@ import {
ProfileStatusBadge, ProfileStatusBadge,
ProfileTypeBadge, ProfileTypeBadge,
RequestDocumentChangeModal, RequestDocumentChangeModal,
ResetPasswordAction,
TableCard, TableCard,
formatBytes, formatBytes,
formatDate, formatDate,
@@ -63,6 +63,8 @@ import {
humanize, humanize,
} from "@/components/customers"; } from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; 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 { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { import {
@@ -85,6 +87,7 @@ import {
usePagination, usePagination,
type ColumnDef, type ColumnDef,
} from "@edr/ui-common"; } 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). */ /** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
function downloadTinRecord(company: Company) { function downloadTinRecord(company: Company) {
@@ -170,6 +173,7 @@ export default function CustomerDetailPage() {
enabled: Boolean(id), enabled: Boolean(id),
}), }),
); );
const contractsQuery = useContractList({ companyId: id, pageSize: 100 }, Boolean(id));
const { pagination: invoicePagination, setPagination: setInvoicePagination } = const { pagination: invoicePagination, setPagination: setInvoicePagination } =
usePagination({ usePagination({
@@ -191,6 +195,7 @@ export default function CustomerDetailPage() {
); );
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : []; const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
const contracts = contractsQuery.data?.items ?? [];
const documents = Array.isArray(documentsQuery.data) const documents = Array.isArray(documentsQuery.data)
? 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( const documentColumns: ColumnDef<CustomerDocument>[] = useMemo(
() => [ () => [
{ {
@@ -709,7 +767,6 @@ export default function CustomerDetailPage() {
<ChangeRequestPendingBadge companyId={company.id} /> <ChangeRequestPendingBadge companyId={company.id} />
</Group> </Group>
} }
action={<ResetPasswordAction company={company} />}
/> />
<Tabs defaultValue="overview"> <Tabs defaultValue="overview">
@@ -720,6 +777,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="bookings" leftSection={<Package size={16} />}> <Tabs.Tab value="bookings" leftSection={<Package size={16} />}>
Bookings Bookings
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="contracts" leftSection={<FileSignature size={16} />}>
Contracts
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<FileText size={16} />}> <Tabs.Tab value="documents" leftSection={<FileText size={16} />}>
Documents Documents
</Tabs.Tab> </Tabs.Tab>
@@ -1187,6 +1247,7 @@ export default function CustomerDetailPage() {
status={tableStatus(bookingsQuery)} status={tableStatus(bookingsQuery)}
emptyMessage="No bookings for this customer." emptyMessage="No bookings for this customer."
containerClassName="border-0 shadow-none bg-transparent" containerClassName="border-0 shadow-none bg-transparent"
onRowClick={(row) => navigate(`/dashboard/booking-requests/${row.id}`)}
error={ error={
bookingsQuery.isError bookingsQuery.isError
? { ? {
@@ -1199,6 +1260,28 @@ export default function CustomerDetailPage() {
</TableCard> </TableCard>
</Tabs.Panel> </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 */} {/* DOCUMENTS */}
<Tabs.Panel value="documents" pt="lg"> <Tabs.Panel value="documents" pt="lg">
<Stack gap="lg"> <Stack gap="lg">

View File

@@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import { import {
ActionIcon, ActionIcon,
Button, Button,
Card, Card,
Center, Center,
Container, Container,
Grid,
Group, Group,
Loader, Loader,
SimpleGrid, SimpleGrid,
@@ -12,7 +14,7 @@ import {
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { useQuery } from "@tanstack/react-query"; 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 { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard"; import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
@@ -26,8 +28,11 @@ import {
humanize, humanize,
} from "@/components/customers"; } from "@/components/customers";
import { PageContainer, PageHeader } from "@/components/page"; 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 { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service"; import { invoicesService } from "@/services/invoices.service";
import type { Invoice } from "@/types/invoice";
function openPdfBlob(blob: Blob, filename: string) { function openPdfBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -43,7 +48,17 @@ function openPdfBlob(blob: Blob, filename: string) {
setTimeout(() => URL.revokeObjectURL(url), 60_000); 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 ( return (
<Stack gap={2}> <Stack gap={2}>
<Text <Text
@@ -56,12 +71,75 @@ function InfoField({ label, value }: { label: string; value?: string | null }) {
{label} {label}
</Text> </Text>
<Text size="sm" c="edr-text"> <Text size="sm" c="edr-text">
{value && value.trim() ? value : "—"} {isEmpty ? "—" : value}
</Text> </Text>
</Stack> </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() { export default function InvoiceDetailPage() {
const { user } = useAuth(); const { user } = useAuth();
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export); const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
@@ -121,7 +199,7 @@ export default function InvoiceDetailPage() {
]} ]}
backTo="/dashboard/invoices" backTo="/dashboard/invoices"
title={invoice.invoiceNumber} title={invoice.invoiceNumber}
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`} subtitle={humanize(invoice.source)}
meta={<InvoiceStatusBadge status={invoice.status} />} meta={<InvoiceStatusBadge status={invoice.status} />}
action={ action={
<ActionIcon <ActionIcon
@@ -138,19 +216,15 @@ export default function InvoiceDetailPage() {
} }
/> />
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg"> <Stack gap="lg">
<Card> <Card>
<Stack gap="lg"> <Stack gap="lg">
<Text fw={600} c="edr-text"> <Text fw={600} c="edr-text">
Summary Amounts
</Text> </Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg"> <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="Currency" value={invoice.currency} />
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} /> <InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
<InfoField label="Due" value={formatDate(invoice.dueAt)} /> <InfoField label="Due" value={formatDate(invoice.dueAt)} />
@@ -257,6 +331,15 @@ export default function InvoiceDetailPage() {
</Stack> </Stack>
</Card> </Card>
</Stack> </Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
<RecipientCard invoice={invoice} />
<SourceCard invoice={invoice} />
</Stack>
</Grid.Col>
</Grid>
</PageContainer> </PageContainer>
); );
} }

View File

@@ -1,4 +1,5 @@
import { import {
ActionIcon,
Alert, Alert,
Badge, Badge,
Box, Box,
@@ -7,6 +8,7 @@ import {
Group, Group,
List, List,
Loader, Loader,
Menu,
Modal, Modal,
Paper, Paper,
RingProgress, RingProgress,
@@ -19,7 +21,6 @@ import {
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { import {
AlertTriangle, AlertTriangle,
ArrowLeft,
CalendarClock, CalendarClock,
CheckCircle2, CheckCircle2,
Clock, Clock,
@@ -29,6 +30,7 @@ import {
FileText, FileText,
History as HistoryIcon, History as HistoryIcon,
LayoutGrid, LayoutGrid,
MoreHorizontal,
Navigation, Navigation,
Package, Package,
PackageCheck, PackageCheck,
@@ -42,7 +44,7 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom"; 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 { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { import {
@@ -886,31 +888,26 @@ export default function TrainScheduleV2DetailPage() {
return ( return (
<PageContainer> <PageContainer>
<Button <PageHeader
component={Link} title={schedule.route?.name ?? "Train schedule"}
to="/dashboard/operations/train-scheduling-v2" backTo="/dashboard/operations/train-scheduling-v2"
variant="subtle" breadcrumbs={[
color="gray" {
size="compact-sm" label: "Train schedules",
leftSection={<ArrowLeft size={16} />} href: "/dashboard/operations/train-scheduling-v2",
w="fit-content" },
> { label: schedule.reference ?? "Schedule" },
Back to schedules ]}
</Button> subtitle={
schedule.train ? (
<Paper <Text size="sm" c="dimmed">
radius="xl" {schedule.train.trainName ?? `Train ${schedule.train.code}`}
p="xl" {schedule.train.trainName ? ` · Train ${schedule.train.code}` : ""}
style={{ position: "relative", overflow: "hidden" }} </Text>
> ) : undefined
<Stack gap="lg" style={{ position: "relative" }}> }
<Group justify="space-between" align="flex-start" wrap="wrap"> meta={
<Group gap="md" align="flex-start" wrap="nowrap"> <Group gap={6} wrap="wrap">
<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 ? ( {schedule.reference ? (
<Badge <Badge
variant="filled" variant="filled"
@@ -921,24 +918,139 @@ export default function TrainScheduleV2DetailPage() {
{schedule.reference} {schedule.reference}
</Badge> </Badge>
) : null} ) : null}
<Title order={2} fw={700} style={{ color: "#0f172a" }}> <FreightTypeBadge freightType={schedule.freightType} />
{schedule.route?.name ?? "Train schedule"} <StatusPill status={schedule.status} />
</Title> {gatepassApplies && gatepassSecured ? (
{schedule.train?.trainName ? ( <Badge
<Text fw={700} style={{ color: "#0f172a" }}> variant="light"
{schedule.train.trainName} color="edr-green"
</Text> radius="sm"
leftSection={<CheckCircle2 size={12} />}
>
Gate pass secured
</Badge>
) : null} ) : null}
{schedule.train ? ( {previewResult ? (
<Text size="xs" c="dimmed" ff="monospace"> <Badge
Train {schedule.train.code} radius="sm"
</Text> 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)",
}}
/>
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null} ) : null}
</Group> </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()}
>
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
</Menu.Item>
) : null}
</Menu.Dropdown>
</Menu>
</Group>
}
/>
{/* Voyage (train) number and trade direction — the two things {/* Ops signage: Train No. / Voyage No. / Direction read at a glance from
operations identify a run by, so they read at a glance across the room, so these stay large rather than folding into the
rather than as small badges among the rest. */} numeric KpiStrip below. */}
<Paper radius="xl" p="lg">
<Stack gap="md">
<Group gap="lg" align="center" wrap="wrap"> <Group gap="lg" align="center" wrap="wrap">
{schedule.trainNumber ? ( {schedule.trainNumber ? (
<Box> <Box>
@@ -970,18 +1082,6 @@ export default function TrainScheduleV2DetailPage() {
</Text> </Text>
</Box> </Box>
) : null} ) : 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 ? ( {schedule.direction ? (
<Box> <Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}> <Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
@@ -1030,140 +1130,6 @@ export default function TrainScheduleV2DetailPage() {
/> />
</Box> </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
>
Gate pass secured
</Button>
) : (
<Button
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={secureGatepass.isPending}
onClick={() => secureGatepass.mutate()}
>
Secure gate pass
</Button>
)
) : null}
</Group>
</Group>
{previewResult ? (
<Badge
size="lg"
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)",
}}
/>
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -16,6 +16,8 @@ export interface BookingListFilter {
tab?: string; tab?: string;
// customerId?: string; // customerId?: string;
companyId?: string; companyId?: string;
/** Bookings drawn down under this contract (contract detail's Shipments tab). */
contractId?: string;
freightType?: string; freightType?: string;
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */ /** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
bookingType?: string; bookingType?: string;
@@ -169,6 +171,7 @@ export const bookingsService = {
if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses; if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses;
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule; if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
if (filter.companyId) params.companyId = filter.companyId; if (filter.companyId) params.companyId = filter.companyId;
if (filter.contractId) params.contractId = filter.contractId;
if (filter.freightType) params.freightType = filter.freightType; if (filter.freightType) params.freightType = filter.freightType;
if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;

View File

@@ -930,10 +930,19 @@ export interface ClearanceView {
importReleaseGranted?: boolean; importReleaseGranted?: boolean;
} }
/** Company an invoice is billed to (minimal projection). */ /**
* Company an invoice is billed to. `findById` returns the full `Company`
* relation, not a stripped projection — these extra fields are what the
* backoffice invoice detail page's recipient card shows.
*/
export interface IInvoiceCompany { export interface IInvoiceCompany {
id: string; id: string;
name: string; name: string;
tin?: string | null;
vatNumber?: string | null;
phone?: string | null;
email?: string | null;
address?: string | null;
} }
/** Company profile (importer/exporter/forwarder/…) an invoice is billed to. */ /** Company profile (importer/exporter/forwarder/…) an invoice is billed to. */