mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user