Enhance contract and booking request handling

This commit is contained in:
Marshal
2026-06-30 02:20:32 +00:00
parent d91389daac
commit a072f04450
8 changed files with 735 additions and 62 deletions

View File

@@ -0,0 +1,275 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
FileCheck,
FileText,
Mail,
MapPin,
Package,
Phone,
Ship,
Truck,
User,
Warehouse,
} from "lucide-react";
import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
type ReqContract = NonNullable<Freight.IBookingRequest["contract"]>;
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
const visible = rows.filter((r) => r.value);
if (visible.length === 0) {
return (
<Text size="sm" c="dimmed">
No details available.
</Text>
);
}
return (
<Stack gap={0}>
{visible.map((row, i) => (
<div key={row.label}>
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))}
</Stack>
);
}
/** Customer (company) on the request's contract. */
export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) {
const company = contract?.company;
if (!company) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<Text size="sm" c="dimmed">
No customer linked to this request.
</Text>
</SectionCard>
);
}
return (
<SectionCard
icon={Building2}
title="Customer"
subtitle={company.name ?? undefined}
accent="blue"
>
<InfoRows
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
]}
/>
</SectionCard>
);
}
const fmtDate = (iso?: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso))
: "—";
const titleCase = (s?: string | null) =>
s ? s.charAt(0) + s.slice(1).toLowerCase() : "—";
/** Contract identity + commercial terms. */
export function RequestContractSummaryCard({
contract,
}: {
contract?: ReqContract | null;
}) {
if (!contract) return null;
return (
<SectionCard
icon={FileText}
title="Contract"
subtitle={contract.reference}
accent="grape"
>
<InfoRows
rows={[
{
icon: FileText,
label: "Kind",
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
},
{
icon: Package,
label: "Cargo",
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
},
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
{
icon: FileCheck,
label: "Customs",
value: contract.customsClearingEnabled
? "Included (Global Logistics)"
: "Not included",
},
{
icon: FileText,
label: "Valid until",
value: contract.contractValidUntil
? fmtDate(contract.contractValidUntil)
: "Not active yet",
},
]}
/>
</SectionCard>
);
}
/** Routes + cargo scope of the contract. */
export function RequestRouteCargoCard({
contract,
}: {
contract?: ReqContract | null;
}) {
const routes = contract?.routes ?? [];
const cargo = contract?.cargoScope ?? [];
const isContainer = contract?.freightType === "CONTAINER";
return (
<SectionCard icon={MapPin} title="Route & cargo" accent="teal">
<Stack gap="md">
<Box>
<Text size="xs" c="dimmed" fw={600} mb={6} tt="uppercase">
Routes
</Text>
{routes.length === 0 ? (
<Text size="sm" c="dimmed">
No routes recorded.
</Text>
) : (
<Stack gap={6}>
{routes.map((r) => (
<Group key={r.id} gap={8} wrap="nowrap">
<MapPin size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500} truncate>
{r.originYard?.label ?? r.originYardId} {" "}
{r.destinationYard?.label ?? r.destinationYardId}
</Text>
</Group>
))}
</Stack>
)}
</Box>
<Box>
<Text size="xs" c="dimmed" fw={600} mb={6} tt="uppercase">
Cargo scope
</Text>
{cargo.length === 0 ? (
<Text size="sm" c="dimmed">
No cargo scope recorded.
</Text>
) : (
<Group gap={6} wrap="wrap">
{cargo.map((c) => (
<Badge
key={c.id}
variant="light"
color="teal"
radius="sm"
leftSection={<Package size={11} />}
>
{c.containerSize ??
c.cargoFreeText ??
(isContainer ? "Container" : "Bulk commodity")}
</Badge>
))}
</Group>
)}
</Box>
</Stack>
</SectionCard>
);
}
/** Service type — what the contracted service bundles (rail-only vs logistics/customs). */
export function RequestServiceTypeCard({
contract,
}: {
contract?: ReqContract | null;
}) {
const st = contract?.serviceType;
if (!st) return null;
const firstMile = st.includesFirstMile ?? false;
const lastMile = st.includesLastMile ?? false;
const customs = st.includesCustoms ?? false;
const railOnly = !firstMile && !lastMile && !customs;
const chips: Array<{ label: string; color: string; icon: LucideIcon }> = [];
if (railOnly) chips.push({ label: "Rail only", color: "blue", icon: Ship });
if (firstMile)
chips.push({ label: "First-mile pickup", color: "teal", icon: Truck });
if (lastMile)
chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse });
if (customs)
chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck });
return (
<SectionCard
icon={Ship}
title="Service"
subtitle={st.serviceName}
accent="indigo"
>
<Stack gap="sm">
<Group gap={6} wrap="wrap">
{chips.map((c) => (
<Badge
key={c.label}
variant="light"
color={c.color}
radius="sm"
leftSection={<c.icon size={11} />}
>
{c.label}
</Badge>
))}
</Group>
{st.description ? (
<Text size="sm" c="dimmed">
{st.description}
</Text>
) : null}
</Stack>
</SectionCard>
);
}

View File

@@ -289,16 +289,18 @@ export default function BookingRequestDetailPage() {
booking={booking}
mutations={mutations}
/>
<Button
fullWidth
variant="default"
leftSection={<Milestone size={16} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/milestones`)
}
>
View clearance milestones
</Button>
{booking.customsClearingEnabled && (
<Button
fullWidth
variant="default"
leftSection={<Milestone size={16} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/milestones`)
}
>
View clearance milestones
</Button>
)}
{showContractButton && (
<Button
fullWidth

View File

@@ -6,6 +6,7 @@ import {
Badge,
Box,
Button,
Grid,
Group,
Loader,
Modal,
@@ -24,6 +25,12 @@ import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import {
RequestCustomerCard,
RequestContractSummaryCard,
RequestRouteCargoCard,
RequestServiceTypeCard,
} from "@/components/contracts/detail/RequestDetailCards";
import { contractsService } from "@/services/contracts.service";
const fmtDate = (iso?: string | null) =>
@@ -167,49 +174,67 @@ export default function ShipmentRequestDetailPage() {
}
/>
<SectionCard icon={CalendarDays} title="Requested shipment">
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Preferred date (informational)
</Text>
<Text size="sm" fw={600}>
{fmtDate(request.scheduledDate)}
</Text>
</Group>
<Box>
<Text size="sm" c="dimmed" mb={6}>
Quantities
</Text>
<Stack gap={4}>
{lineRows(request.requestedLines ?? {}).map((l, i) => (
<Badge
key={i}
variant="light"
color="edr-green"
radius="sm"
size="lg"
>
{l}
</Badge>
))}
</Stack>
</Box>
{request.notes ? (
<Box>
<Text size="sm" c="dimmed" mb={4}>
Customer note
</Text>
<Text size="sm">{request.notes}</Text>
</Box>
) : null}
{request.reviewNote ? (
<Alert color="red" variant="light" radius="md" mt="sm">
Rejected: {request.reviewNote}
</Alert>
) : null}
</Stack>
</SectionCard>
<Grid gap="lg">
{/* LEFT — the request itself + route/cargo scope */}
<Grid.Col span={{ base: 12, lg: 7 }}>
<Stack gap="lg">
<SectionCard icon={CalendarDays} title="Requested shipment">
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Preferred date (informational)
</Text>
<Text size="sm" fw={600}>
{fmtDate(request.scheduledDate)}
</Text>
</Group>
<Box>
<Text size="sm" c="dimmed" mb={6}>
Quantities
</Text>
<Stack gap={4}>
{lineRows(request.requestedLines ?? {}).map((l, i) => (
<Badge
key={i}
variant="light"
color="edr-green"
radius="sm"
size="lg"
>
{l}
</Badge>
))}
</Stack>
</Box>
{request.notes ? (
<Box>
<Text size="sm" c="dimmed" mb={4}>
Customer note
</Text>
<Text size="sm">{request.notes}</Text>
</Box>
) : null}
{request.reviewNote ? (
<Alert color="red" variant="light" radius="md" mt="sm">
Rejected: {request.reviewNote}
</Alert>
) : null}
</Stack>
</SectionCard>
<RequestRouteCargoCard contract={request.contract} />
</Stack>
</Grid.Col>
{/* RIGHT — customer, contract + service-type context */}
<Grid.Col span={{ base: 12, lg: 5 }}>
<Stack gap="lg">
<RequestCustomerCard contract={request.contract} />
<RequestContractSummaryCard contract={request.contract} />
<RequestServiceTypeCard contract={request.contract} />
</Stack>
</Grid.Col>
</Grid>
</Stack>
<Modal