mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
style: inter module integration
This commit is contained in:
@@ -413,6 +413,32 @@ export class BillingService {
|
|||||||
return `data:image/png;base64,${signedQr}`;
|
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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Anchor as AnchorIcon } from "lucide-react";
|
||||||
|
import { Code } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import { LinkedEntityCard } from "@/components/detail";
|
||||||
|
import type { FieldRowProps } from "@/components/detail";
|
||||||
|
|
||||||
|
export interface BookingContractCardProps {
|
||||||
|
booking: BookingDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parent contract quick info for the booking, linking to its detail page. */
|
||||||
|
export function BookingContractCard({ booking }: BookingContractCardProps) {
|
||||||
|
if (!booking.contractId || !booking.contractReference) return null;
|
||||||
|
|
||||||
|
const rows: FieldRowProps[] = [
|
||||||
|
{
|
||||||
|
label: "Kind",
|
||||||
|
value: booking.contractKind === "GENERAL" ? "General" : "One-time",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinkedEntityCard
|
||||||
|
icon={AnchorIcon}
|
||||||
|
title="Contract"
|
||||||
|
name={booking.contractReference}
|
||||||
|
to={`/dashboard/contract-requests/${booking.contractId}`}
|
||||||
|
accent="teal"
|
||||||
|
rows={rows}
|
||||||
|
footer={
|
||||||
|
booking.contractSummary ? (
|
||||||
|
<Code
|
||||||
|
block
|
||||||
|
mt={4}
|
||||||
|
style={{
|
||||||
|
maxHeight: 220,
|
||||||
|
overflow: "auto",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{booking.contractSummary}
|
||||||
|
</Code>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Anchor } from "lucide-react";
|
|
||||||
import { Code } from "@mantine/core";
|
|
||||||
|
|
||||||
import { SectionCard } from "./SectionCard";
|
|
||||||
|
|
||||||
export interface BookingContractSummaryCardProps {
|
|
||||||
summary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generated contract terms, shown verbatim. */
|
|
||||||
export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) {
|
|
||||||
return (
|
|
||||||
<SectionCard icon={Anchor} title="Contract summary" accent="teal">
|
|
||||||
<Code
|
|
||||||
block
|
|
||||||
style={{
|
|
||||||
maxHeight: 256,
|
|
||||||
overflow: "auto",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
background: "var(--mantine-color-gray-0)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{summary}
|
|
||||||
</Code>
|
|
||||||
</SectionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { Truck } from "lucide-react";
|
import { 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,24 +9,40 @@ 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">
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
|
<Stack gap="md">
|
||||||
{booking.firstMilePickupAddress && (
|
{hasAddresses && (
|
||||||
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
|
||||||
|
{booking.firstMilePickupAddress && (
|
||||||
|
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
|
||||||
|
)}
|
||||||
|
{booking.lastMileDeliveryAddress && (
|
||||||
|
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
{booking.lastMileDeliveryAddress && (
|
{handoverSection}
|
||||||
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
|
</Stack>
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Building2,
|
|
||||||
Calendar,
|
|
||||||
Clock,
|
|
||||||
Container as ContainerIcon,
|
|
||||||
Flame,
|
|
||||||
RefreshCw,
|
|
||||||
Wallet,
|
|
||||||
Weight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
|
||||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
|
||||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
|
||||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
|
||||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
|
||||||
|
|
||||||
import { formatDate } from "./booking-detail.styles";
|
|
||||||
|
|
||||||
export interface BookingRequestHeroProps {
|
|
||||||
booking: BookingDetail;
|
|
||||||
customerLabel: string;
|
|
||||||
onBack: () => void;
|
|
||||||
onRefresh: () => void;
|
|
||||||
isFetching?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Top hero for the request detail page: identity, status, next step, key figures. */
|
|
||||||
export function BookingRequestHero({
|
|
||||||
booking,
|
|
||||||
customerLabel,
|
|
||||||
onBack,
|
|
||||||
onRefresh,
|
|
||||||
isFetching,
|
|
||||||
}: BookingRequestHeroProps) {
|
|
||||||
const amount = Number(booking.totalAmount);
|
|
||||||
const containers = booking.bookingContainers ?? [];
|
|
||||||
const containerCount = containers.reduce(
|
|
||||||
(sum, c) => sum + Number(c.quantity ?? 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
radius="xl"
|
|
||||||
p="xl"
|
|
||||||
style={{ position: "relative", overflow: "hidden" }}
|
|
||||||
>
|
|
||||||
<Stack gap="lg" style={{ position: "relative" }}>
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="compact-sm"
|
|
||||||
radius="lg"
|
|
||||||
leftSection={<ArrowLeft size={16} />}
|
|
||||||
onClick={onBack}
|
|
||||||
>
|
|
||||||
Back to list
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
size="compact-sm"
|
|
||||||
radius="lg"
|
|
||||||
leftSection={<RefreshCw size={15} />}
|
|
||||||
loading={isFetching}
|
|
||||||
onClick={onRefresh}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
|
||||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
fw={700}
|
|
||||||
tt="uppercase"
|
|
||||||
style={{ letterSpacing: 1, color: "#B26C09" }}
|
|
||||||
>
|
|
||||||
Booking reference
|
|
||||||
</Text>
|
|
||||||
<Group gap="sm" align="center" wrap="wrap">
|
|
||||||
<Stack gap={2} miw={0}>
|
|
||||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
|
||||||
{booking.reference}
|
|
||||||
</Title>
|
|
||||||
<ContractReferenceLink
|
|
||||||
contractId={booking.contractId}
|
|
||||||
contractReference={booking.contractReference}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<BookingStatusBadge status={booking.status} />
|
|
||||||
<BookingPriorityBadge score={booking.priorityScore} />
|
|
||||||
{booking.schedulingStatus ? (
|
|
||||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
|
||||||
<Text size="xs" c="orange.7">
|
|
||||||
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Group gap="lg" mt={4}>
|
|
||||||
<MetaItem icon={Building2} text={customerLabel} strong />
|
|
||||||
<MetaItem
|
|
||||||
icon={Calendar}
|
|
||||||
text={`Scheduled ${booking.scheduledDate}`}
|
|
||||||
/>
|
|
||||||
<MetaItem
|
|
||||||
icon={Clock}
|
|
||||||
text={`Created ${formatDate(booking.createdAt)}`}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{booking.nextStep ? (
|
|
||||||
<Paper
|
|
||||||
radius="lg"
|
|
||||||
p={4}
|
|
||||||
maw={640}
|
|
||||||
style={{
|
|
||||||
background: "var(--mantine-color-gray-0)",
|
|
||||||
border: "1px solid var(--mantine-color-gray-2)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<NextStepBanner nextStep={booking.nextStep} />
|
|
||||||
</Paper>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Group grow gap="md" align="stretch" wrap="wrap">
|
|
||||||
<HeroTile
|
|
||||||
icon={Wallet}
|
|
||||||
label="Total value"
|
|
||||||
value={`${booking.paymentCurrency} ${amount.toLocaleString(
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
},
|
|
||||||
)}`}
|
|
||||||
hint={booking.paymentStatus}
|
|
||||||
accent="edr-green"
|
|
||||||
/>
|
|
||||||
<HeroTile
|
|
||||||
icon={Weight}
|
|
||||||
label="Cargo weight"
|
|
||||||
value={`${weight} T`}
|
|
||||||
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
|
|
||||||
accent="blue"
|
|
||||||
/>
|
|
||||||
<HeroTile
|
|
||||||
icon={ContainerIcon}
|
|
||||||
label="Containers"
|
|
||||||
value={containerCount || "—"}
|
|
||||||
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
|
|
||||||
accent="teal"
|
|
||||||
/>
|
|
||||||
<HeroTile
|
|
||||||
icon={Flame}
|
|
||||||
label="Priority score"
|
|
||||||
value={booking.priorityScore ?? 0}
|
|
||||||
hint={booking.tradeDirection}
|
|
||||||
accent="orange"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetaItem({
|
|
||||||
icon: Icon,
|
|
||||||
text,
|
|
||||||
strong,
|
|
||||||
}: {
|
|
||||||
icon: LucideIcon;
|
|
||||||
text: ReactNode;
|
|
||||||
strong?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<Icon size={14} color="var(--mantine-color-gray-5)" />
|
|
||||||
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "dark" : "dimmed"}>
|
|
||||||
{text}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeroTile({
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
hint,
|
|
||||||
accent = "edr-green",
|
|
||||||
}: {
|
|
||||||
icon: LucideIcon;
|
|
||||||
label: string;
|
|
||||||
value: ReactNode;
|
|
||||||
hint?: ReactNode;
|
|
||||||
accent?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
p="md"
|
|
||||||
radius="lg"
|
|
||||||
style={{
|
|
||||||
flex: "1 1 160px",
|
|
||||||
minWidth: 150,
|
|
||||||
background: "var(--mantine-color-gray-0)",
|
|
||||||
border: "1px solid var(--mantine-color-gray-2)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
|
||||||
<ThemeIcon size={36} radius="md" variant="light" color={accent}>
|
|
||||||
<Icon size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
fw={600}
|
|
||||||
tt="uppercase"
|
|
||||||
c="dimmed"
|
|
||||||
style={{ letterSpacing: 0.4 }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
{hint ? (
|
|
||||||
<Text size="xs" c="dimmed" truncate>
|
|
||||||
{hint}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,10 +16,9 @@ export * from "./BookingPaymentCard";
|
|||||||
export * from "./BookingPaymentCountdownCard";
|
export * from "./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";
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Badge } from "@mantine/core";
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
PENDING: "edr-green",
|
||||||
|
ACCEPTED: "blue",
|
||||||
|
REJECTED: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Status of a customer-submitted shipment (booking) request against a contract. */
|
||||||
|
export function BookingRequestStatusBadge({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<Badge variant="light" radius="sm" color={STATUS_COLOR[status] ?? "gray"}>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import { clearanceWorkflowFileLabel } from "@edr/types";
|
|||||||
|
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { 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,25 +142,23 @@ 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"
|
||||||
>
|
rows={[
|
||||||
<InfoRows
|
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||||
rows={[
|
{ icon: Hash, label: "VAT number", value: company.vatNumber },
|
||||||
{ icon: FileCheck, label: "TIN", value: company.tin },
|
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
|
||||||
{ icon: Hash, label: "VAT number", value: company.vatNumber },
|
{ icon: Globe, label: "Country", value: company.country },
|
||||||
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
|
{ icon: Mail, label: "Email", value: company.email },
|
||||||
{ icon: Globe, label: "Country", value: company.country },
|
{ icon: Phone, label: "Phone", value: company.phone },
|
||||||
{ icon: Mail, label: "Email", value: company.email },
|
{ icon: MapPin, label: "Address", value: company.address },
|
||||||
{ icon: Phone, label: "Phone", value: company.phone },
|
{ icon: Globe, label: "Website", value: company.website },
|
||||||
{ icon: MapPin, label: "Address", value: company.address },
|
]}
|
||||||
{ 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
|
||||||
|
|||||||
@@ -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,23 +33,21 @@ 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"
|
||||||
>
|
rows={[
|
||||||
<InfoRows
|
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||||
rows={[
|
{ icon: Mail, label: "Email", value: company.email },
|
||||||
{ icon: FileCheck, label: "TIN", value: company.tin },
|
{ icon: Phone, label: "Phone", value: company.phone },
|
||||||
{ icon: Mail, label: "Email", value: company.email },
|
{ icon: MapPin, label: "Address", value: company.address },
|
||||||
{ icon: Phone, label: "Phone", value: company.phone },
|
{ icon: User, label: "Contact", value: company.contactPersonName },
|
||||||
{ icon: MapPin, label: "Address", value: company.address },
|
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
|
||||||
{ icon: User, label: "Contact", value: company.contactPersonName },
|
]}
|
||||||
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
|
/>
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,43 +75,41 @@ 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"
|
||||||
>
|
rows={[
|
||||||
<InfoRows
|
{
|
||||||
rows={[
|
icon: FileText,
|
||||||
{
|
label: "Kind",
|
||||||
icon: FileText,
|
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
|
||||||
label: "Kind",
|
},
|
||||||
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
|
{
|
||||||
},
|
icon: Package,
|
||||||
{
|
label: "Cargo",
|
||||||
icon: Package,
|
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
|
||||||
label: "Cargo",
|
},
|
||||||
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
|
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
|
||||||
},
|
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
|
||||||
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
|
{
|
||||||
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
|
icon: FileCheck,
|
||||||
{
|
label: "Customs",
|
||||||
icon: FileCheck,
|
value: contract.customsClearingEnabled
|
||||||
label: "Customs",
|
? "Included (Global Logistics)"
|
||||||
value: contract.customsClearingEnabled
|
: "Not included",
|
||||||
? "Included (Global Logistics)"
|
},
|
||||||
: "Not included",
|
{
|
||||||
},
|
icon: FileText,
|
||||||
{
|
label: "Valid until",
|
||||||
icon: FileText,
|
value: contract.contractValidUntil
|
||||||
label: "Valid until",
|
? fmtDate(contract.contractValidUntil)
|
||||||
value: contract.contractValidUntil
|
: "Not active yet",
|
||||||
? fmtDate(contract.contractValidUntil)
|
},
|
||||||
: "Not active yet",
|
]}
|
||||||
},
|
/>
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import { Alert, Button, Loader, Modal, Radio, Stack, Text } from "@mantine/core";
|
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { KeyRound } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import type { Company, ResetChannel } from "@/types/customer";
|
|
||||||
|
|
||||||
export interface ResetPasswordActionProps {
|
|
||||||
company: Pick<Company, "id">;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Staff-triggered password reset. Sends a single-use link to the customer's
|
|
||||||
* primary contact; the customer opens it and picks their own new password. No
|
|
||||||
* credential is ever shown to or handled by staff.
|
|
||||||
*/
|
|
||||||
export default function ResetPasswordAction({
|
|
||||||
company,
|
|
||||||
}: ResetPasswordActionProps) {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [opened, setOpened] = useState(false);
|
|
||||||
const [channel, setChannel] = useState<ResetChannel>("phone");
|
|
||||||
|
|
||||||
const allowed = hasPermission(user, FREIGHT_PERMS.customers.resetPassword);
|
|
||||||
|
|
||||||
// The destination is the primary contact's IAM account, not the company
|
|
||||||
// record — those are different fields and routinely hold different values, so
|
|
||||||
// showing `company.phone` here would tell staff the wrong number. Only fetched
|
|
||||||
// once the modal is open.
|
|
||||||
const targetQuery = useQuery(
|
|
||||||
api.customers.resetTarget.queryOptions({
|
|
||||||
input: { companyId: company.id },
|
|
||||||
enabled: allowed && opened,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const target = targetQuery.data;
|
|
||||||
|
|
||||||
const { mutate, isPending } = useMutation(
|
|
||||||
api.customers.resetPassword.mutationOptions({
|
|
||||||
onSuccess: (result) => {
|
|
||||||
setOpened(false);
|
|
||||||
toast({
|
|
||||||
title: "Reset link sent",
|
|
||||||
description: `The customer can set a new password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
title: "Could not send reset link",
|
|
||||||
description: error.message,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!allowed) return null;
|
|
||||||
|
|
||||||
// SMS is domestic-only: a foreign number counts as unavailable, same as a
|
|
||||||
// missing one, so staff can't send a link that will never arrive.
|
|
||||||
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
|
|
||||||
const channelMissing =
|
|
||||||
!!target && (channel === "email" ? !target.email : !phoneUsable);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<KeyRound size={16} />}
|
|
||||||
onClick={() => setOpened(true)}
|
|
||||||
>
|
|
||||||
Reset password
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={() => setOpened(false)}
|
|
||||||
title="Send a password-reset link"
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
We'll send a single-use link to this customer's primary
|
|
||||||
contact. They choose their own new password — you will not see it.
|
|
||||||
The link expires in 24 hours.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{targetQuery.isLoading ? (
|
|
||||||
<Stack align="center" py="md">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Stack>
|
|
||||||
) : targetQuery.isError ? (
|
|
||||||
<Alert color="red" variant="light">
|
|
||||||
{targetQuery.error.message}
|
|
||||||
</Alert>
|
|
||||||
) : target ? (
|
|
||||||
<>
|
|
||||||
<Radio.Group
|
|
||||||
value={channel}
|
|
||||||
onChange={(v) => setChannel(v as ResetChannel)}
|
|
||||||
label={`Send the link to ${target.name || "the primary contact"} via`}
|
|
||||||
>
|
|
||||||
<Stack gap="xs" mt="xs">
|
|
||||||
<Radio
|
|
||||||
value="phone"
|
|
||||||
label="SMS"
|
|
||||||
disabled={!phoneUsable}
|
|
||||||
description={
|
|
||||||
!target.phone
|
|
||||||
? "No phone number on this account"
|
|
||||||
: target.phoneIsDomestic === false
|
|
||||||
? `${target.phone} — foreign number, SMS unavailable; use email`
|
|
||||||
: target.phone
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Radio
|
|
||||||
value="email"
|
|
||||||
label="Email"
|
|
||||||
disabled={!target.email}
|
|
||||||
description={
|
|
||||||
target.email ?? "No email address on this account"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Radio.Group>
|
|
||||||
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
These are the primary contact's own login details, which may
|
|
||||||
differ from the company contact details on the profile.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
loading={isPending}
|
|
||||||
disabled={channelMissing}
|
|
||||||
onClick={() => mutate({ companyId: company.id, channel })}
|
|
||||||
>
|
|
||||||
Send reset link
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,10 +19,6 @@ export {
|
|||||||
RequestDocumentChangeModal,
|
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,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import { Anchor, Group, Text } from "@mantine/core";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export interface EntityLinkProps {
|
||||||
|
/** Route to the related record's detail page. Renders nothing if falsy — a
|
||||||
|
* link with no id would be a dead one (e.g. a government booking with no
|
||||||
|
* company). */
|
||||||
|
to?: string | null;
|
||||||
|
label: ReactNode;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
/** Monospace label — for references/codes (e.g. "CT-2024-0117"). */
|
||||||
|
mono?: boolean;
|
||||||
|
size?: "xs" | "sm" | "md";
|
||||||
|
fw?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline link to another record's detail page, with a small "go to" glyph so
|
||||||
|
* it reads as navigation rather than plain emphasis. `stopPropagation` matters
|
||||||
|
* wherever this sits inside a clickable table row (booking/invoice rows
|
||||||
|
* navigate on click) — without it a nested link races the row handler.
|
||||||
|
*/
|
||||||
|
export function EntityLink({
|
||||||
|
to,
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
mono,
|
||||||
|
size = "sm",
|
||||||
|
fw = 600,
|
||||||
|
className,
|
||||||
|
}: EntityLinkProps) {
|
||||||
|
if (!to) {
|
||||||
|
return (
|
||||||
|
<Text size={size} fw={fw} c="dimmed" ff={mono ? "monospace" : undefined}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
to={to}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
underline="hover"
|
||||||
|
c="edr-green"
|
||||||
|
fw={fw}
|
||||||
|
fz={size}
|
||||||
|
ff={mono ? "monospace" : undefined}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<Group gap={4} wrap="nowrap" component="span" style={{ display: "inline-flex" }}>
|
||||||
|
{Icon ? <Icon size={14} /> : null}
|
||||||
|
<span>{label}</span>
|
||||||
|
<ArrowUpRight size={13} style={{ flexShrink: 0 }} />
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Group, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
export interface FieldProps {
|
||||||
|
label: string;
|
||||||
|
value?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stacked label-over-value pair — uppercase dimmed label, value below. Used in
|
||||||
|
* grids of facts (e.g. an invoice summary, a contract's key figures).
|
||||||
|
*/
|
||||||
|
export function Field({ label, value }: FieldProps) {
|
||||||
|
const isEmpty = value === undefined || value === null || value === "";
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
c="edr-muted"
|
||||||
|
tt="uppercase"
|
||||||
|
style={{ letterSpacing: "0.04em" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="edr-text">
|
||||||
|
{isEmpty ? "—" : value}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldRowProps {
|
||||||
|
icon?: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left icon+label / right bold value row, divider-separated when stacked in a
|
||||||
|
* list. Used inside quick-info cards (see `LinkedEntityCard`).
|
||||||
|
*/
|
||||||
|
export function FieldRow({ icon: Icon, label, value }: FieldRowProps) {
|
||||||
|
const isEmpty = value === undefined || value === null || value === "";
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
{Icon ? <Icon size={15} color="var(--mantine-color-gray-5)" /> : null}
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
|
||||||
|
{isEmpty ? "—" : value}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Divider, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
|
import { FieldRow, type FieldRowProps } from "./Field";
|
||||||
|
import { EntityLink } from "./EntityLink";
|
||||||
|
|
||||||
|
export interface LinkedEntityCardProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
/** Card title, e.g. "Customer" or "Contract". */
|
||||||
|
title: string;
|
||||||
|
/** The entity's own name/reference, rendered as the linked subtitle. */
|
||||||
|
name: ReactNode;
|
||||||
|
/** Route to the entity's detail page. Omit when there's nothing to link to
|
||||||
|
* (e.g. a government booking with no company) — the name renders as plain
|
||||||
|
* dimmed text instead of a dead link. */
|
||||||
|
to?: string | null;
|
||||||
|
accent?: string;
|
||||||
|
/** Quick-info rows shown below the linked name — empty ones are dropped. */
|
||||||
|
rows?: FieldRowProps[];
|
||||||
|
/** Extra content under the rows (e.g. a summary paragraph, an action). */
|
||||||
|
footer?: ReactNode;
|
||||||
|
/** Shown instead of rows/footer when there's nothing to display at all. */
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Customer at a glance" / "Contract at a glance" card for a detail page's
|
||||||
|
* sticky rail: a linked title plus a handful of quick-info rows, so the
|
||||||
|
* related record's essentials are visible without navigating away.
|
||||||
|
*/
|
||||||
|
export function LinkedEntityCard({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
name,
|
||||||
|
to,
|
||||||
|
accent = "blue",
|
||||||
|
rows = [],
|
||||||
|
footer,
|
||||||
|
emptyMessage,
|
||||||
|
}: LinkedEntityCardProps) {
|
||||||
|
const visibleRows = rows.filter((r) => r.value !== undefined && r.value !== null && r.value !== "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard icon={icon} title={title} accent={accent}>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<EntityLink to={to} label={name} size="sm" fw={700} />
|
||||||
|
{visibleRows.length > 0 ? (
|
||||||
|
<Stack gap={0} mt={4}>
|
||||||
|
{visibleRows.map((row, index) => (
|
||||||
|
<div key={row.label}>
|
||||||
|
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
|
||||||
|
<FieldRow {...row} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : emptyMessage ? (
|
||||||
|
<Text size="sm" c="dimmed" mt={4}>
|
||||||
|
{emptyMessage}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{footer}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export { Field, FieldRow } from "./Field";
|
||||||
|
export type { FieldProps, FieldRowProps } from "./Field";
|
||||||
|
export { EntityLink } from "./EntityLink";
|
||||||
|
export type { EntityLinkProps } from "./EntityLink";
|
||||||
|
export { LinkedEntityCard } from "./LinkedEntityCard";
|
||||||
|
export type { LinkedEntityCardProps } from "./LinkedEntityCard";
|
||||||
|
|
||||||
|
// Re-exported so pages under this restructure have one import path for both
|
||||||
|
// the new quick-info primitives and the existing section-card shell. Imported
|
||||||
|
// from the file directly (not the bookings/detail barrel) — that barrel also
|
||||||
|
// re-exports cards that import from this module, and going through it would
|
||||||
|
// create a circular import.
|
||||||
|
export { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
|
export type { SectionCardProps } from "@/components/bookings/detail/SectionCard";
|
||||||
@@ -7,7 +7,7 @@ import Breadcrumbs, { type BreadcrumbItem } from "@/components/ui/Breadcrumbs";
|
|||||||
|
|
||||||
export interface PageHeaderProps {
|
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. */
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
<Text size="sm" fw={700} truncate>
|
{bookingId ? (
|
||||||
{reference}
|
<EntityLink
|
||||||
</Text>
|
to={`/dashboard/booking-requests/${bookingId}`}
|
||||||
|
label={reference}
|
||||||
|
size="sm"
|
||||||
|
fw={700}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" fw={700} truncate>
|
||||||
|
{reference}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{status ? <BookingStatusBadge status={status} /> : null}
|
{status ? <BookingStatusBadge status={status} /> : null}
|
||||||
{intercity ? (
|
{intercity ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,97 +432,6 @@ export default function BookingRequestDetailPage() {
|
|||||||
booking={booking}
|
booking={booking}
|
||||||
mutations={mutations}
|
mutations={mutations}
|
||||||
/>
|
/>
|
||||||
{booking.tradeDirection === "EXPORT" && (
|
|
||||||
<Paper withBorder radius="md" p="sm">
|
|
||||||
<Stack gap={6}>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
How the cargo reaches the train
|
|
||||||
</Text>
|
|
||||||
<SegmentedControl
|
|
||||||
fullWidth
|
|
||||||
size="xs"
|
|
||||||
value={booking.exportHandoverMode ?? "WAREHOUSE"}
|
|
||||||
data={[
|
|
||||||
{ value: "WAREHOUSE", label: "Warehouse then train" },
|
|
||||||
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
|
|
||||||
]}
|
|
||||||
onChange={async (value) => {
|
|
||||||
try {
|
|
||||||
await bookingsService.setExportHandoverMode(
|
|
||||||
booking.id,
|
|
||||||
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
|
|
||||||
);
|
|
||||||
await refetch();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Could not change the handover mode",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
|
|
||||||
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
|
|
||||||
: "Cargo is received at the warehouse and issued a GRN before loading."}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
{booking.isGovernment && booking.contractSummary && (
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="default"
|
|
||||||
leftSection={<FileSignature size={16} />}
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/dashboard/booking-requests/${booking.id}/contract`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View / sign contract
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="default"
|
|
||||||
leftSection={<FileText size={16} />}
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
const blob =
|
|
||||||
await bookingsService.downloadCarriageAcceptanceSheet(
|
|
||||||
booking.id,
|
|
||||||
);
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `carriage-acceptance-${booking.reference}.pdf`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Carriage acceptance sheet is not available yet",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Carriage acceptance sheet
|
|
||||||
</Button>
|
|
||||||
{booking.customsClearingEnabled && (
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="default"
|
|
||||||
leftSection={<Milestone size={16} />}
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/dashboard/bookings/${booking.id}/clearance`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View document clearance
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
@@ -368,11 +444,13 @@ export default function BookingRequestDetailPage() {
|
|||||||
/** The booking's primary detail cards — route, services, cargo, containers. */
|
/** The booking's primary detail cards — route, services, cargo, containers. */
|
||||||
function OverviewPanel({
|
function OverviewPanel({
|
||||||
booking,
|
booking,
|
||||||
row,
|
onRefetch,
|
||||||
}: {
|
}: {
|
||||||
booking: BookingDetail;
|
booking: BookingDetail;
|
||||||
row: ReturnType<typeof toBookingListRow>;
|
onRefetch: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const row = toBookingListRow(booking);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<BookingRouteServiceCard
|
<BookingRouteServiceCard
|
||||||
@@ -380,12 +458,49 @@ function OverviewPanel({
|
|||||||
originLabel={row.originLabel}
|
originLabel={row.originLabel}
|
||||||
destinationLabel={row.destinationLabel}
|
destinationLabel={row.destinationLabel}
|
||||||
/>
|
/>
|
||||||
<BookingMileServicesCard booking={booking} />
|
<BookingMileServicesCard
|
||||||
|
booking={booking}
|
||||||
|
handoverSection={
|
||||||
|
booking.tradeDirection === "EXPORT" ? (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
How the cargo reaches the train
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
fullWidth
|
||||||
|
size="xs"
|
||||||
|
value={booking.exportHandoverMode ?? "WAREHOUSE"}
|
||||||
|
data={[
|
||||||
|
{ value: "WAREHOUSE", label: "Warehouse then train" },
|
||||||
|
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
|
||||||
|
]}
|
||||||
|
onChange={async (value) => {
|
||||||
|
try {
|
||||||
|
await bookingsService.setExportHandoverMode(
|
||||||
|
booking.id,
|
||||||
|
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
|
||||||
|
);
|
||||||
|
onRefetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Could not change the handover mode",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
|
||||||
|
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
|
||||||
|
: "Cargo is received at the warehouse and issued a GRN before loading."}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
<BookingCargoCard booking={booking} />
|
<BookingCargoCard booking={booking} />
|
||||||
<BookingContainerUnitsCard booking={booking} />
|
<BookingContainerUnitsCard booking={booking} />
|
||||||
{booking.contractSummary && (
|
|
||||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,25 +196,46 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
{ label: reference },
|
{ label: reference },
|
||||||
]}
|
]}
|
||||||
meta={
|
meta={
|
||||||
clearance.allApproved ? (
|
<Group gap={6} wrap="wrap">
|
||||||
<Badge
|
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
|
||||||
variant="light"
|
{direction}
|
||||||
color="edr-green"
|
|
||||||
radius="sm"
|
|
||||||
leftSection={<CheckCircle2 size={13} />}
|
|
||||||
>
|
|
||||||
All approved
|
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
{clearance.includesCustoms ? (
|
||||||
<Badge
|
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
|
||||||
variant="light"
|
Customs
|
||||||
color="gray"
|
</Badge>
|
||||||
radius="sm"
|
) : null}
|
||||||
leftSection={<Clock size={13} />}
|
{clearance.allApproved ? (
|
||||||
>
|
<Badge
|
||||||
Review pending
|
variant="light"
|
||||||
</Badge>
|
color="edr-green"
|
||||||
)
|
radius="sm"
|
||||||
|
leftSection={<CheckCircle2 size={13} />}
|
||||||
|
>
|
||||||
|
All approved
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<Clock size={13} />}
|
||||||
|
>
|
||||||
|
Review pending
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text size="sm" c="dimmed" fw={600}>
|
||||||
|
{origin}
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||||
|
<Text size="sm" c="dimmed" fw={600}>
|
||||||
|
{destination}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
}
|
}
|
||||||
action={
|
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,67 +312,54 @@ 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 }}>
|
||||||
{isPhasedGeneral ? (
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
<PhasedClearanceActionPanel
|
<Stack gap="lg">
|
||||||
bookingId={id!}
|
{booking ? <BookingCompanyCard booking={booking} /> : null}
|
||||||
clearance={clearance}
|
{booking ? <BookingContractCard booking={booking} /> : null}
|
||||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
{isPhasedGeneral ? (
|
||||||
workflowFiles={workflowFiles}
|
<PhasedClearanceActionPanel
|
||||||
roleMode="ET"
|
bookingId={id!}
|
||||||
// A bare initiated instance still has no cargo/price — the
|
clearance={clearance}
|
||||||
// stepper's "Create booking" step must read as NOT-yet-created
|
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||||
// so it never claims the booking is done before GL completes it.
|
workflowFiles={workflowFiles}
|
||||||
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
|
roleMode="ET"
|
||||||
bookingMilestones={bookingMilestones ?? []}
|
// A bare initiated instance still has no cargo/price — the
|
||||||
onChanged={() => void refetch()}
|
// stepper's "Create booking" step must read as NOT-yet-created
|
||||||
onViewFile={view}
|
// so it never claims the booking is done before GL completes it.
|
||||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
|
||||||
/>
|
bookingMilestones={bookingMilestones ?? []}
|
||||||
) : (
|
onChanged={() => void refetch()}
|
||||||
<Box style={{ position: "sticky", top: 24 }}>
|
onViewFile={view}
|
||||||
<SectionCard
|
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
icon={PackageCheck}
|
/>
|
||||||
title="Review progress"
|
) : (
|
||||||
accent="edr-green"
|
<SectionCard
|
||||||
>
|
icon={PackageCheck}
|
||||||
<Stack align="center" gap="sm">
|
title="Review progress"
|
||||||
<RingProgress
|
accent="edr-green"
|
||||||
size={140}
|
>
|
||||||
thickness={12}
|
<Stack align="center" gap="sm">
|
||||||
roundCaps
|
<RingProgress
|
||||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
size={140}
|
||||||
label={
|
thickness={12}
|
||||||
<Stack gap={0} align="center">
|
roundCaps
|
||||||
<Text fw={800} fz={26} lh={1}>
|
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||||
{stats.pct}%
|
label={
|
||||||
</Text>
|
<Stack gap={0} align="center">
|
||||||
<Text size="xs" c="dimmed">
|
<Text fw={800} fz={26} lh={1}>
|
||||||
approved
|
{stats.pct}%
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
<Text size="xs" c="dimmed">
|
||||||
}
|
approved
|
||||||
/>
|
</Text>
|
||||||
<Group gap="lg" justify="center">
|
</Stack>
|
||||||
<ProgressStat
|
}
|
||||||
color="edr-green"
|
|
||||||
label="Approved"
|
|
||||||
value={stats.approved}
|
|
||||||
/>
|
/>
|
||||||
<ProgressStat
|
</Stack>
|
||||||
color="red"
|
</SectionCard>
|
||||||
label="Queried"
|
)}
|
||||||
value={stats.queried}
|
</Stack>
|
||||||
/>
|
</Box>
|
||||||
<ProgressStat
|
|
||||||
color="gray"
|
|
||||||
label="Pending"
|
|
||||||
value={stats.pending}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</SectionCard>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,57 +228,81 @@ 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
|
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
|
||||||
variant="light"
|
{directionLabel(direction)}
|
||||||
color="orange"
|
|
||||||
radius="sm"
|
|
||||||
leftSection={<RefreshCw size={13} />}
|
|
||||||
>
|
|
||||||
Payment expired — rebook
|
|
||||||
</Badge>
|
</Badge>
|
||||||
) : bookingAlreadyCreated ? (
|
{customs ? (
|
||||||
<Badge
|
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
|
||||||
variant="light"
|
Customs
|
||||||
color="blue"
|
</Badge>
|
||||||
radius="sm"
|
) : null}
|
||||||
leftSection={<PackageCheck size={13} />}
|
{bookingExpired ? (
|
||||||
>
|
<Badge
|
||||||
Booking created
|
variant="light"
|
||||||
</Badge>
|
color="orange"
|
||||||
) : ready ? (
|
radius="sm"
|
||||||
<Badge
|
leftSection={<RefreshCw size={13} />}
|
||||||
variant="light"
|
>
|
||||||
color="edr-green"
|
Payment expired — rebook
|
||||||
radius="sm"
|
</Badge>
|
||||||
leftSection={<PackageCheck size={13} />}
|
) : bookingAlreadyCreated ? (
|
||||||
>
|
<Badge
|
||||||
Ready — create booking
|
variant="light"
|
||||||
</Badge>
|
color="blue"
|
||||||
) : clearance.allApproved ? (
|
radius="sm"
|
||||||
<Badge
|
leftSection={<PackageCheck size={13} />}
|
||||||
variant="light"
|
>
|
||||||
color="edr-green"
|
Booking created
|
||||||
radius="sm"
|
</Badge>
|
||||||
leftSection={<CheckCircle2 size={13} />}
|
) : ready ? (
|
||||||
>
|
<Badge
|
||||||
All approved
|
variant="light"
|
||||||
</Badge>
|
color="edr-green"
|
||||||
) : (
|
radius="sm"
|
||||||
<Badge
|
leftSection={<PackageCheck size={13} />}
|
||||||
variant="light"
|
>
|
||||||
color="gray"
|
Ready — create booking
|
||||||
radius="sm"
|
</Badge>
|
||||||
leftSection={<Clock size={13} />}
|
) : clearance.allApproved ? (
|
||||||
>
|
<Badge
|
||||||
Review pending
|
variant="light"
|
||||||
</Badge>
|
color="edr-green"
|
||||||
)
|
radius="sm"
|
||||||
|
leftSection={<CheckCircle2 size={13} />}
|
||||||
|
>
|
||||||
|
All approved
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<Clock size={13} />}
|
||||||
|
>
|
||||||
|
Review pending
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ClearanceHero contract={contract} stats={stats} />
|
<KpiStrip items={kpis} />
|
||||||
|
|
||||||
{/* Windows on this contract's routes/direction only — tells GL ET when
|
{/* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,9 +29,10 @@ import { useAuth } from "@/auth/useAuth";
|
|||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { 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,37 +292,44 @@ export default function GlClearanceDetailPage() {
|
|||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
<PhasedClearanceActionPanel
|
<Stack gap="md">
|
||||||
contractId={data.kind === "contract" ? id : undefined}
|
{data.kind === "contract" ? (
|
||||||
bookingId={data.kind === "booking" ? id : linkedBookingId}
|
<RequestCustomerCard contract={data.contract} />
|
||||||
// For a per-booking instance, "created" means COMPLETED (has
|
) : (
|
||||||
// cargo/price), not merely that a booking row exists — a bare
|
<BookingCompanyCard booking={data.booking} />
|
||||||
// instance is not yet a real booking. Contract-level clearance
|
)}
|
||||||
// keeps its linked-booking signal.
|
<PhasedClearanceActionPanel
|
||||||
bookingCreated={
|
contractId={data.kind === "contract" ? id : undefined}
|
||||||
data.kind === "booking"
|
bookingId={data.kind === "booking" ? id : linkedBookingId}
|
||||||
? bookingCompleted
|
// For a per-booking instance, "created" means COMPLETED (has
|
||||||
: Boolean(linkedBookingId)
|
// cargo/price), not merely that a booking row exists — a bare
|
||||||
}
|
// instance is not yet a real booking. Contract-level clearance
|
||||||
bookingMilestones={
|
// keeps its linked-booking signal.
|
||||||
data.kind === "booking"
|
bookingCreated={
|
||||||
? (data.clearance.milestones ?? [])
|
data.kind === "booking"
|
||||||
: (bookingMilestones ?? [])
|
? bookingCompleted
|
||||||
}
|
: Boolean(linkedBookingId)
|
||||||
clearance={data.clearance}
|
}
|
||||||
tradeDirection={data.tradeDirection}
|
bookingMilestones={
|
||||||
workflowFiles={workflowFiles}
|
data.kind === "booking"
|
||||||
roleMode="DJ"
|
? (data.clearance.milestones ?? [])
|
||||||
useUploadModals
|
: (bookingMilestones ?? [])
|
||||||
onUploadDoRequest={() => setUploadKind("do")}
|
}
|
||||||
onUploadRoRequest={() => setUploadKind("ro")}
|
clearance={data.clearance}
|
||||||
onChanged={() => {
|
tradeDirection={data.tradeDirection}
|
||||||
void refetch();
|
workflowFiles={workflowFiles}
|
||||||
refetchBookingMilestonesIfLinked();
|
roleMode="DJ"
|
||||||
}}
|
useUploadModals
|
||||||
onViewFile={view}
|
onUploadDoRequest={() => setUploadKind("do")}
|
||||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
onUploadRoRequest={() => setUploadKind("ro")}
|
||||||
/>
|
onChanged={() => {
|
||||||
|
void refetch();
|
||||||
|
refetchBookingMilestonesIfLinked();
|
||||||
|
}}
|
||||||
|
onViewFile={view}
|
||||||
|
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,125 +216,130 @@ export default function InvoiceDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack gap="lg">
|
<Grid gap="lg">
|
||||||
<Card>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Text fw={600} c="edr-text">
|
<Card>
|
||||||
Summary
|
<Stack gap="lg">
|
||||||
</Text>
|
<Text fw={600} c="edr-text">
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
Amounts
|
||||||
<InfoField label="Billed to" value={invoice.company?.name} />
|
</Text>
|
||||||
<InfoField
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||||
label="Profile"
|
<InfoField label="Currency" value={invoice.currency} />
|
||||||
value={invoice.companyProfile?.reference}
|
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
|
||||||
/>
|
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
|
||||||
<InfoField label="Type" value={humanize(invoice.type)} />
|
<InfoField
|
||||||
<InfoField label="Currency" value={invoice.currency} />
|
label="Total"
|
||||||
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
|
value={formatMoney(invoice.totalAmount, invoice.currency)}
|
||||||
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
|
/>
|
||||||
<InfoField
|
<InfoField
|
||||||
label="Total"
|
label="Balance"
|
||||||
value={formatMoney(invoice.totalAmount, invoice.currency)}
|
value={formatMoney(invoice.balanceAmount, invoice.currency)}
|
||||||
/>
|
/>
|
||||||
<InfoField
|
</SimpleGrid>
|
||||||
label="Balance"
|
</Stack>
|
||||||
value={formatMoney(invoice.balanceAmount, invoice.currency)}
|
</Card>
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
<EimsFilingCard invoiceId={invoice.id} />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Line items
|
||||||
|
</Text>
|
||||||
|
<Table striped withRowBorders={false} verticalSpacing="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Description</Table.Th>
|
||||||
|
<Table.Th>Charge type</Table.Th>
|
||||||
|
<Table.Th ta="right">Quantity</Table.Th>
|
||||||
|
<Table.Th ta="right">Unit rate</Table.Th>
|
||||||
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(invoice.lines ?? []).map((line) => (
|
||||||
|
<Table.Tr key={line.id}>
|
||||||
|
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{humanize(line.chargeType)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">{line.quantity}</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
{formatMoney(line.unitRate, line.currency)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
{formatMoney(line.amount, line.currency)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
{(invoice.lines ?? []).length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5}>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||||
|
No line items.
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
justify="flex-end"
|
||||||
|
gap="xl"
|
||||||
|
pt="sm"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{formatMoney(invoice.subtotalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
Tax
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{formatMoney(invoice.taxAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
Paid
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{formatMoney(invoice.paidAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" fw={700}>
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{formatMoney(invoice.totalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Grid.Col>
|
||||||
|
|
||||||
<EimsFilingCard invoiceId={invoice.id} />
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Stack gap="lg">
|
||||||
<Card>
|
<RecipientCard invoice={invoice} />
|
||||||
<Stack gap="md">
|
<SourceCard invoice={invoice} />
|
||||||
<Text fw={600} c="edr-text">
|
|
||||||
Line items
|
|
||||||
</Text>
|
|
||||||
<Table striped withRowBorders={false} verticalSpacing="xs">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Description</Table.Th>
|
|
||||||
<Table.Th>Charge type</Table.Th>
|
|
||||||
<Table.Th ta="right">Quantity</Table.Th>
|
|
||||||
<Table.Th ta="right">Unit rate</Table.Th>
|
|
||||||
<Table.Th ta="right">Amount</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{(invoice.lines ?? []).map((line) => (
|
|
||||||
<Table.Tr key={line.id}>
|
|
||||||
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{humanize(line.chargeType)}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td ta="right">{line.quantity}</Table.Td>
|
|
||||||
<Table.Td ta="right">
|
|
||||||
{formatMoney(line.unitRate, line.currency)}
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td ta="right">
|
|
||||||
{formatMoney(line.amount, line.currency)}
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
{(invoice.lines ?? []).length === 0 && (
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td colSpan={5}>
|
|
||||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
|
||||||
No line items.
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<Group
|
|
||||||
justify="flex-end"
|
|
||||||
gap="xl"
|
|
||||||
pt="sm"
|
|
||||||
style={{
|
|
||||||
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap={2} align="flex-end">
|
|
||||||
<Text size="xs" c="edr-muted">
|
|
||||||
Subtotal
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">
|
|
||||||
{formatMoney(invoice.subtotalAmount, invoice.currency)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={2} align="flex-end">
|
|
||||||
<Text size="xs" c="edr-muted">
|
|
||||||
Tax
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">
|
|
||||||
{formatMoney(invoice.taxAmount, invoice.currency)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={2} align="flex-end">
|
|
||||||
<Text size="xs" c="edr-muted">
|
|
||||||
Paid
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">
|
|
||||||
{formatMoney(invoice.paidAmount, invoice.currency)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={2} align="flex-end">
|
|
||||||
<Text size="xs" fw={700}>
|
|
||||||
Total
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" fw={700}>
|
|
||||||
{formatMoney(invoice.totalAmount, invoice.currency)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Grid.Col>
|
||||||
</Stack>
|
</Grid>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,284 +888,248 @@ 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">
|
{schedule.reference ? (
|
||||||
<Train size={28} />
|
<Badge
|
||||||
</ThemeIcon>
|
variant="filled"
|
||||||
<Stack gap={6}>
|
color="edr-green"
|
||||||
<Group gap="sm" align="center" wrap="wrap">
|
radius="sm"
|
||||||
{schedule.reference ? (
|
style={{ fontWeight: 700, fontFamily: "monospace" }}
|
||||||
<Badge
|
>
|
||||||
variant="filled"
|
{schedule.reference}
|
||||||
color="edr-green"
|
</Badge>
|
||||||
radius="sm"
|
) : null}
|
||||||
style={{ fontWeight: 700, fontFamily: "monospace" }}
|
<FreightTypeBadge freightType={schedule.freightType} />
|
||||||
>
|
<StatusPill status={schedule.status} />
|
||||||
{schedule.reference}
|
{gatepassApplies && gatepassSecured ? (
|
||||||
</Badge>
|
<Badge
|
||||||
) : null}
|
variant="light"
|
||||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
color="edr-green"
|
||||||
{schedule.route?.name ?? "Train schedule"}
|
radius="sm"
|
||||||
</Title>
|
leftSection={<CheckCircle2 size={12} />}
|
||||||
{schedule.train?.trainName ? (
|
>
|
||||||
<Text fw={700} style={{ color: "#0f172a" }}>
|
Gate pass secured
|
||||||
{schedule.train.trainName}
|
</Badge>
|
||||||
</Text>
|
) : null}
|
||||||
) : null}
|
{previewResult ? (
|
||||||
{schedule.train ? (
|
<Badge
|
||||||
<Text size="xs" c="dimmed" ff="monospace">
|
radius="sm"
|
||||||
Train {schedule.train.code}
|
variant="light"
|
||||||
</Text>
|
color={previewResult.valid ? "edr-green" : "red"}
|
||||||
) : null}
|
leftSection={
|
||||||
</Group>
|
<Box
|
||||||
|
w={8}
|
||||||
{/* Voyage (train) number and trade direction — the two things
|
h={8}
|
||||||
operations identify a run by, so they read at a glance
|
style={{
|
||||||
rather than as small badges among the rest. */}
|
borderRadius: 999,
|
||||||
<Group gap="lg" align="center" wrap="wrap">
|
background: previewResult.valid
|
||||||
{schedule.trainNumber ? (
|
? "var(--mantine-color-edr-green-6)"
|
||||||
<Box>
|
: "var(--mantine-color-red-6)",
|
||||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
}}
|
||||||
Train No.
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
ff="monospace"
|
|
||||||
fw={800}
|
|
||||||
lh={1.1}
|
|
||||||
style={{ fontSize: 32, color: "#0f172a" }}
|
|
||||||
>
|
|
||||||
{schedule.trainNumber}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
{schedule.voyageNumber ? (
|
|
||||||
<Box>
|
|
||||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
|
||||||
Voyage No.
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
ff="monospace"
|
|
||||||
fw={800}
|
|
||||||
lh={1.1}
|
|
||||||
style={{ fontSize: 32, color: "#0f172a" }}
|
|
||||||
>
|
|
||||||
{schedule.voyageNumber}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
{/* Merging rewrites the consist, so it is offered only while
|
|
||||||
the departure can still be edited. */}
|
|
||||||
{canEditBookings ? (
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
size="compact-sm"
|
|
||||||
leftSection={<Merge size={14} />}
|
|
||||||
onClick={() => setMergeModalOpen(true)}
|
|
||||||
>
|
|
||||||
Merge
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{schedule.direction ? (
|
|
||||||
<Box>
|
|
||||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
|
||||||
Direction
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
fw={800}
|
|
||||||
lh={1.1}
|
|
||||||
tt="uppercase"
|
|
||||||
style={{
|
|
||||||
fontSize: 32,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
color:
|
|
||||||
schedule.direction === "IMPORT"
|
|
||||||
? "#2E5B96"
|
|
||||||
: schedule.direction === "EXPORT"
|
|
||||||
? "#0A6F4D"
|
|
||||||
: "#0f172a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{schedule.direction}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
{(schedule.stops?.length ?? 0) >= 3 ||
|
|
||||||
(schedule.bookings ?? []).some(
|
|
||||||
(b) => b.tradeDirection === "DOMESTIC",
|
|
||||||
) ? (
|
|
||||||
<SegmentOccupancyStrip
|
|
||||||
stops={schedule.stops ?? []}
|
|
||||||
bookings={schedule.bookings ?? []}
|
|
||||||
maxWagons={schedule.maxWagons}
|
|
||||||
maxGrossTons={schedule.maxGrossWeightTons}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
}
|
||||||
<Box maw={340}>
|
>
|
||||||
<RouteCorridor
|
Preview {previewResult.valid ? "valid" : "has issues"}
|
||||||
origin={
|
</Badge>
|
||||||
schedule.originStation?.label ?? schedule.originStation?.code
|
) : null}
|
||||||
}
|
</Group>
|
||||||
destination={
|
}
|
||||||
schedule.destinationStation?.label ??
|
action={
|
||||||
schedule.destinationStation?.code
|
<Group gap="sm" wrap="nowrap">
|
||||||
}
|
{/* Merging rewrites the consist, so it is offered only while
|
||||||
/>
|
the departure can still be edited. */}
|
||||||
</Box>
|
{canEditBookings ? (
|
||||||
)}
|
<Button
|
||||||
<Group gap="sm" align="center">
|
variant="light"
|
||||||
<FreightTypeBadge freightType={schedule.freightType} />
|
size="compact-sm"
|
||||||
<StatusPill status={schedule.status} />
|
leftSection={<Merge size={14} />}
|
||||||
</Group>
|
onClick={() => setMergeModalOpen(true)}
|
||||||
</Stack>
|
>
|
||||||
</Group>
|
Merge
|
||||||
<Group gap="sm">
|
</Button>
|
||||||
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
|
) : null}
|
||||||
<Button
|
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
|
||||||
variant="gradient"
|
<Button
|
||||||
gradient={{ from: "#0f172a", to: "#334155" }}
|
variant="gradient"
|
||||||
radius="lg"
|
gradient={{ from: "#0f172a", to: "#334155" }}
|
||||||
size="sm"
|
radius="lg"
|
||||||
leftSection={<Eye size={16} />}
|
size="compact-sm"
|
||||||
onClick={() => setVisualization3DOpen(true)}
|
leftSection={<Eye size={16} />}
|
||||||
>
|
onClick={() => setVisualization3DOpen(true)}
|
||||||
3D Visualization
|
>
|
||||||
</Button>
|
3D Visualization
|
||||||
) : null}
|
</Button>
|
||||||
{canPrintMarshalling ? (
|
) : null}
|
||||||
<Button
|
<Menu position="bottom-end" width={240} withinPortal>
|
||||||
variant="light"
|
<Menu.Target>
|
||||||
color="edr-green"
|
<ActionIcon variant="default" size="lg" radius="md" aria-label="More actions">
|
||||||
radius="lg"
|
<MoreHorizontal size={16} />
|
||||||
size="sm"
|
</ActionIcon>
|
||||||
leftSection={<FileText size={16} />}
|
</Menu.Target>
|
||||||
loading={downloadMarshalling.isPending}
|
<Menu.Dropdown>
|
||||||
onClick={() => void openMarshallingDocument()}
|
{canPrintMarshalling ? (
|
||||||
>
|
<Menu.Item
|
||||||
Marshalling PDF
|
leftSection={<FileText size={15} />}
|
||||||
</Button>
|
disabled={downloadMarshalling.isPending}
|
||||||
) : null}
|
onClick={() => void openMarshallingDocument()}
|
||||||
{["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
|
Marshalling PDF
|
||||||
</Button>
|
</Menu.Item>
|
||||||
) : (
|
) : null}
|
||||||
<Button
|
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||||
color="edr-green"
|
<Menu.Item
|
||||||
radius="lg"
|
leftSection={<FileText size={15} />}
|
||||||
size="sm"
|
disabled={downloadMarshalling.isPending}
|
||||||
leftSection={<FileText size={16} />}
|
onClick={() =>
|
||||||
loading={secureGatepass.isPending}
|
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()}
|
onClick={() => secureGatepass.mutate()}
|
||||||
>
|
>
|
||||||
Secure gate pass
|
Secure gate pass
|
||||||
</Button>
|
</Menu.Item>
|
||||||
)
|
) : null}
|
||||||
) : null}
|
</Menu.Dropdown>
|
||||||
</Group>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{previewResult ? (
|
{/* Ops signage: Train No. / Voyage No. / Direction read at a glance from
|
||||||
<Badge
|
across the room, so these stay large rather than folding into the
|
||||||
size="lg"
|
numeric KpiStrip below. */}
|
||||||
radius="sm"
|
<Paper radius="xl" p="lg">
|
||||||
variant="light"
|
<Stack gap="md">
|
||||||
color={previewResult.valid ? "edr-green" : "red"}
|
<Group gap="lg" align="center" wrap="wrap">
|
||||||
leftSection={
|
{schedule.trainNumber ? (
|
||||||
<Box
|
<Box>
|
||||||
w={8}
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||||
h={8}
|
Train No.
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
ff="monospace"
|
||||||
|
fw={800}
|
||||||
|
lh={1.1}
|
||||||
|
style={{ fontSize: 32, color: "#0f172a" }}
|
||||||
|
>
|
||||||
|
{schedule.trainNumber}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
{schedule.voyageNumber ? (
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||||
|
Voyage No.
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
ff="monospace"
|
||||||
|
fw={800}
|
||||||
|
lh={1.1}
|
||||||
|
style={{ fontSize: 32, color: "#0f172a" }}
|
||||||
|
>
|
||||||
|
{schedule.voyageNumber}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
{schedule.direction ? (
|
||||||
|
<Box>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||||
|
Direction
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
fw={800}
|
||||||
|
lh={1.1}
|
||||||
|
tt="uppercase"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 999,
|
fontSize: 32,
|
||||||
background: previewResult.valid
|
letterSpacing: 0.5,
|
||||||
? "var(--mantine-color-edr-green-6)"
|
color:
|
||||||
: "var(--mantine-color-red-6)",
|
schedule.direction === "IMPORT"
|
||||||
|
? "#2E5B96"
|
||||||
|
: schedule.direction === "EXPORT"
|
||||||
|
? "#0A6F4D"
|
||||||
|
: "#0f172a",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
}
|
{schedule.direction}
|
||||||
>
|
</Text>
|
||||||
Preview {previewResult.valid ? "valid" : "has issues"}
|
</Box>
|
||||||
</Badge>
|
) : null}
|
||||||
) : null}
|
</Group>
|
||||||
|
{(schedule.stops?.length ?? 0) >= 3 ||
|
||||||
|
(schedule.bookings ?? []).some(
|
||||||
|
(b) => b.tradeDirection === "DOMESTIC",
|
||||||
|
) ? (
|
||||||
|
<SegmentOccupancyStrip
|
||||||
|
stops={schedule.stops ?? []}
|
||||||
|
bookings={schedule.bookings ?? []}
|
||||||
|
maxWagons={schedule.maxWagons}
|
||||||
|
maxGrossTons={schedule.maxGrossWeightTons}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box maw={340}>
|
||||||
|
<RouteCorridor
|
||||||
|
origin={
|
||||||
|
schedule.originStation?.label ?? schedule.originStation?.code
|
||||||
|
}
|
||||||
|
destination={
|
||||||
|
schedule.destinationStation?.label ??
|
||||||
|
schedule.destinationStation?.code
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user