style: inter module integration

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

View File

@@ -1,44 +1,16 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
FileCheck,
Mail,
MapPin,
Phone,
User,
} from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import { Building2, FileCheck, Mail, MapPin, Phone, User } from "lucide-react";
import { Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { LinkedEntityCard } from "@/components/detail";
import type { FieldRowProps } from "@/components/detail";
import { SectionCard } from "./SectionCard";
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
export interface BookingCompanyCardProps {
booking: BookingDetail;
}
/** Customer (company) information for the booking. */
/** Customer (company) quick info for the booking, linking to its detail page. */
export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
const company = booking.company;
@@ -46,11 +18,9 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
if (!company && booking.isGovernment) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<InfoRow
icon={Building2}
label="Government"
value={booking.governmentInstitution}
/>
<Text size="sm" fw={600}>
{booking.governmentInstitution ?? "Government"}
</Text>
</SectionCard>
);
}
@@ -67,36 +37,24 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
const companyName = company.companyName ?? company.name ?? company.label;
const rows: InfoRowProps[] = [
const rows: FieldRowProps[] = [
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact person", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
].filter((r) => r.value);
];
return (
<SectionCard
<LinkedEntityCard
icon={Building2}
title="Customer"
subtitle={companyName}
name={companyName ?? "Unnamed company"}
to={company.id ? `/dashboard/customers/${company.id}` : null}
accent="blue"
>
<Stack gap={0}>
{rows.length === 0 ? (
<Text size="sm" c="dimmed">
No additional company details available.
</Text>
) : (
rows.map((row, index) => (
<div key={row.label}>
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))
)}
</Stack>
</SectionCard>
rows={rows}
emptyMessage="No additional company details available."
/>
);
}

View File

@@ -0,0 +1,49 @@
import { Anchor as AnchorIcon } from "lucide-react";
import { Code } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { LinkedEntityCard } from "@/components/detail";
import type { FieldRowProps } from "@/components/detail";
export interface BookingContractCardProps {
booking: BookingDetail;
}
/** Parent contract quick info for the booking, linking to its detail page. */
export function BookingContractCard({ booking }: BookingContractCardProps) {
if (!booking.contractId || !booking.contractReference) return null;
const rows: FieldRowProps[] = [
{
label: "Kind",
value: booking.contractKind === "GENERAL" ? "General" : "One-time",
},
];
return (
<LinkedEntityCard
icon={AnchorIcon}
title="Contract"
name={booking.contractReference}
to={`/dashboard/contract-requests/${booking.contractId}`}
accent="teal"
rows={rows}
footer={
booking.contractSummary ? (
<Code
block
mt={4}
style={{
maxHeight: 220,
overflow: "auto",
whiteSpace: "pre-wrap",
background: "var(--mantine-color-gray-0)",
}}
>
{booking.contractSummary}
</Code>
) : undefined
}
/>
);
}

View File

@@ -1,27 +0,0 @@
import { Anchor } from "lucide-react";
import { Code } from "@mantine/core";
import { SectionCard } from "./SectionCard";
export interface BookingContractSummaryCardProps {
summary: string;
}
/** Generated contract terms, shown verbatim. */
export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) {
return (
<SectionCard icon={Anchor} title="Contract summary" accent="teal">
<Code
block
style={{
maxHeight: 256,
overflow: "auto",
whiteSpace: "pre-wrap",
background: "var(--mantine-color-gray-0)",
}}
>
{summary}
</Code>
</SectionCard>
);
}

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { Truck } from "lucide-react";
import { SimpleGrid } from "@mantine/core";
import { SimpleGrid, Stack } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
@@ -8,24 +9,40 @@ import { MetricTile } from "./MetricTile";
export interface BookingMileServicesCardProps {
booking: BookingDetail;
/** Export handover-mode control — how the cargo reaches the train. Lives
* here because it's the other "how does the cargo physically travel" fact;
* shown even when no mile address is set, since EXPORT bookings still need
* the choice made. */
handoverSection?: ReactNode;
}
/** First / last mile addresses. Renders nothing when neither is present. */
export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) {
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
/** First / last mile addresses, plus the export handover control. Renders
* nothing when none of the three are present. */
export function BookingMileServicesCard({
booking,
handoverSection,
}: BookingMileServicesCardProps) {
const hasAddresses =
Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress);
if (!hasAddresses && !handoverSection) {
return null;
}
return (
<SectionCard icon={Truck} title="Mile services" accent="grape">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{booking.firstMilePickupAddress && (
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
<Stack gap="md">
{hasAddresses && (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{booking.firstMilePickupAddress && (
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
)}
{booking.lastMileDeliveryAddress && (
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
)}
</SimpleGrid>
)}
{booking.lastMileDeliveryAddress && (
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
)}
</SimpleGrid>
{handoverSection}
</Stack>
</SectionCard>
);
}

View File

@@ -1,258 +0,0 @@
import type { ReactNode } from "react";
import {
ArrowLeft,
Building2,
Calendar,
Clock,
Container as ContainerIcon,
Flame,
RefreshCw,
Wallet,
Weight,
} from "lucide-react";
import {
Button,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { formatDate } from "./booking-detail.styles";
export interface BookingRequestHeroProps {
booking: BookingDetail;
customerLabel: string;
onBack: () => void;
onRefresh: () => void;
isFetching?: boolean;
}
/** Top hero for the request detail page: identity, status, next step, key figures. */
export function BookingRequestHero({
booking,
customerLabel,
onBack,
onRefresh,
isFetching,
}: BookingRequestHeroProps) {
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
const containerCount = containers.reduce(
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
return (
<Paper
radius="xl"
p="xl"
style={{ position: "relative", overflow: "hidden" }}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Button
variant="default"
size="compact-sm"
radius="lg"
leftSection={<ArrowLeft size={16} />}
onClick={onBack}
>
Back to list
</Button>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
loading={isFetching}
onClick={onRefresh}
>
Refresh
</Button>
</Group>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Text
size="xs"
fw={700}
tt="uppercase"
style={{ letterSpacing: 1, color: "#B26C09" }}
>
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Stack gap={2} miw={0}>
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<ContractReferenceLink
contractId={booking.contractId}
contractReference={booking.contractReference}
/>
</Stack>
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group>
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="orange.7">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</Text>
) : null}
<Group gap="lg" mt={4}>
<MetaItem icon={Building2} text={customerLabel} strong />
<MetaItem
icon={Calendar}
text={`Scheduled ${booking.scheduledDate}`}
/>
<MetaItem
icon={Clock}
text={`Created ${formatDate(booking.createdAt)}`}
/>
</Group>
</Stack>
</Group>
{booking.nextStep ? (
<Paper
radius="lg"
p={4}
maw={640}
style={{
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<NextStepBanner nextStep={booking.nextStep} />
</Paper>
) : null}
<Group grow gap="md" align="stretch" wrap="wrap">
<HeroTile
icon={Wallet}
label="Total value"
value={`${booking.paymentCurrency} ${amount.toLocaleString(
undefined,
{
minimumFractionDigits: 2,
},
)}`}
hint={booking.paymentStatus}
accent="edr-green"
/>
<HeroTile
icon={Weight}
label="Cargo weight"
value={`${weight} T`}
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
accent="blue"
/>
<HeroTile
icon={ContainerIcon}
label="Containers"
value={containerCount || "—"}
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
accent="teal"
/>
<HeroTile
icon={Flame}
label="Priority score"
value={booking.priorityScore ?? 0}
hint={booking.tradeDirection}
accent="orange"
/>
</Group>
</Stack>
</Paper>
);
}
function MetaItem({
icon: Icon,
text,
strong,
}: {
icon: LucideIcon;
text: ReactNode;
strong?: boolean;
}) {
return (
<Group gap={6} wrap="nowrap">
<Icon size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "dark" : "dimmed"}>
{text}
</Text>
</Group>
);
}
function HeroTile({
icon: Icon,
label,
value,
hint,
accent = "edr-green",
}: {
icon: LucideIcon;
label: string;
value: ReactNode;
hint?: ReactNode;
accent?: string;
}) {
return (
<Paper
p="md"
radius="lg"
style={{
flex: "1 1 160px",
minWidth: 150,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<ThemeIcon size={36} radius="md" variant="light" color={accent}>
<Icon size={18} />
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text
size="xs"
fw={600}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: 0.4 }}
>
{label}
</Text>
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>
{value}
</Text>
{hint ? (
<Text size="xs" c="dimmed" truncate>
{hint}
</Text>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

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

View File

@@ -0,0 +1,16 @@
import { Badge } from "@mantine/core";
const STATUS_COLOR: Record<string, string> = {
PENDING: "edr-green",
ACCEPTED: "blue",
REJECTED: "red",
};
/** Status of a customer-submitted shipment (booking) request against a contract. */
export function BookingRequestStatusBadge({ status }: { status: string }) {
return (
<Badge variant="light" radius="sm" color={STATUS_COLOR[status] ?? "gray"}>
{status}
</Badge>
);
}

View File

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

View File

@@ -12,56 +12,14 @@ import {
User,
Warehouse,
} from "lucide-react";
import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core";
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { LinkedEntityCard } from "@/components/detail";
type ReqContract = NonNullable<Freight.IBookingRequest["contract"]>;
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
const visible = rows.filter((r) => r.value);
if (visible.length === 0) {
return (
<Text size="sm" c="dimmed">
No details available.
</Text>
);
}
return (
<Stack gap={0}>
{visible.map((row, i) => (
<div key={row.label}>
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))}
</Stack>
);
}
/** Customer (company) on the request's contract. */
export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) {
const company = contract?.company;
@@ -75,23 +33,21 @@ export function RequestCustomerCard({ contract }: { contract?: ReqContract | nul
);
}
return (
<SectionCard
<LinkedEntityCard
icon={Building2}
title="Customer"
subtitle={company.name ?? undefined}
name={company.name ?? "Unnamed company"}
to={company.id ? `/dashboard/customers/${company.id}` : null}
accent="blue"
>
<InfoRows
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
]}
/>
</SectionCard>
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
]}
/>
);
}
@@ -119,43 +75,41 @@ export function RequestContractSummaryCard({
}) {
if (!contract) return null;
return (
<SectionCard
<LinkedEntityCard
icon={FileText}
title="Contract"
subtitle={contract.reference}
name={contract.reference}
to={`/dashboard/contract-requests/${contract.id}`}
accent="grape"
>
<InfoRows
rows={[
{
icon: FileText,
label: "Kind",
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
},
{
icon: Package,
label: "Cargo",
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
},
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
{
icon: FileCheck,
label: "Customs",
value: contract.customsClearingEnabled
? "Included (Global Logistics)"
: "Not included",
},
{
icon: FileText,
label: "Valid until",
value: contract.contractValidUntil
? fmtDate(contract.contractValidUntil)
: "Not active yet",
},
]}
/>
</SectionCard>
rows={[
{
icon: FileText,
label: "Kind",
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
},
{
icon: Package,
label: "Cargo",
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
},
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
{
icon: FileCheck,
label: "Customs",
value: contract.customsClearingEnabled
? "Included (Global Logistics)"
: "Not included",
},
{
icon: FileText,
label: "Valid until",
value: contract.contractValidUntil
? fmtDate(contract.contractValidUntil)
: "Not active yet",
},
]}
/>
);
}

View File

@@ -1,151 +0,0 @@
import { Alert, Button, Loader, Modal, Radio, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { KeyRound } from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { Company, ResetChannel } from "@/types/customer";
export interface ResetPasswordActionProps {
company: Pick<Company, "id">;
}
/**
* Staff-triggered password reset. Sends a single-use link to the customer's
* primary contact; the customer opens it and picks their own new password. No
* credential is ever shown to or handled by staff.
*/
export default function ResetPasswordAction({
company,
}: ResetPasswordActionProps) {
const { user } = useAuth();
const { toast } = useToast();
const [opened, setOpened] = useState(false);
const [channel, setChannel] = useState<ResetChannel>("phone");
const allowed = hasPermission(user, FREIGHT_PERMS.customers.resetPassword);
// The destination is the primary contact's IAM account, not the company
// record — those are different fields and routinely hold different values, so
// showing `company.phone` here would tell staff the wrong number. Only fetched
// once the modal is open.
const targetQuery = useQuery(
api.customers.resetTarget.queryOptions({
input: { companyId: company.id },
enabled: allowed && opened,
}),
);
const target = targetQuery.data;
const { mutate, isPending } = useMutation(
api.customers.resetPassword.mutationOptions({
onSuccess: (result) => {
setOpened(false);
toast({
title: "Reset link sent",
description: `The customer can set a new password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
});
},
onError: (error) => {
toast({
title: "Could not send reset link",
description: error.message,
variant: "destructive",
});
},
}),
);
if (!allowed) return null;
// SMS is domestic-only: a foreign number counts as unavailable, same as a
// missing one, so staff can't send a link that will never arrive.
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
const channelMissing =
!!target && (channel === "email" ? !target.email : !phoneUsable);
return (
<>
<Button
variant="default"
leftSection={<KeyRound size={16} />}
onClick={() => setOpened(true)}
>
Reset password
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Send a password-reset link"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
We&apos;ll send a single-use link to this customer&apos;s primary
contact. They choose their own new password you will not see it.
The link expires in 24 hours.
</Text>
{targetQuery.isLoading ? (
<Stack align="center" py="md">
<Loader size="sm" />
</Stack>
) : targetQuery.isError ? (
<Alert color="red" variant="light">
{targetQuery.error.message}
</Alert>
) : target ? (
<>
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
label={`Send the link to ${target.name || "the primary contact"} via`}
>
<Stack gap="xs" mt="xs">
<Radio
value="phone"
label="SMS"
disabled={!phoneUsable}
description={
!target.phone
? "No phone number on this account"
: target.phoneIsDomestic === false
? `${target.phone} — foreign number, SMS unavailable; use email`
: target.phone
}
/>
<Radio
value="email"
label="Email"
disabled={!target.email}
description={
target.email ?? "No email address on this account"
}
/>
</Stack>
</Radio.Group>
<Text size="xs" c="dimmed">
These are the primary contact&apos;s own login details, which may
differ from the company contact details on the profile.
</Text>
<Button
color="edr-green"
loading={isPending}
disabled={channelMissing}
onClick={() => mutate({ companyId: company.id, channel })}
>
Send reset link
</Button>
</>
) : null}
</Stack>
</Modal>
</>
);
}

View File

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

View File

@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { ArrowUpRight } from "lucide-react";
import { Anchor, Group, Text } from "@mantine/core";
import { Link } from "react-router-dom";
export interface EntityLinkProps {
/** Route to the related record's detail page. Renders nothing if falsy — a
* link with no id would be a dead one (e.g. a government booking with no
* company). */
to?: string | null;
label: ReactNode;
icon?: LucideIcon;
/** Monospace label — for references/codes (e.g. "CT-2024-0117"). */
mono?: boolean;
size?: "xs" | "sm" | "md";
fw?: number;
className?: string;
}
/**
* Inline link to another record's detail page, with a small "go to" glyph so
* it reads as navigation rather than plain emphasis. `stopPropagation` matters
* wherever this sits inside a clickable table row (booking/invoice rows
* navigate on click) — without it a nested link races the row handler.
*/
export function EntityLink({
to,
label,
icon: Icon,
mono,
size = "sm",
fw = 600,
className,
}: EntityLinkProps) {
if (!to) {
return (
<Text size={size} fw={fw} c="dimmed" ff={mono ? "monospace" : undefined}>
{label}
</Text>
);
}
return (
<Anchor
component={Link}
to={to}
onClick={(e) => e.stopPropagation()}
underline="hover"
c="edr-green"
fw={fw}
fz={size}
ff={mono ? "monospace" : undefined}
className={className}
>
<Group gap={4} wrap="nowrap" component="span" style={{ display: "inline-flex" }}>
{Icon ? <Icon size={14} /> : null}
<span>{label}</span>
<ArrowUpRight size={13} style={{ flexShrink: 0 }} />
</Group>
</Anchor>
);
}

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { Group, Stack, Text } from "@mantine/core";
export interface FieldProps {
label: string;
value?: ReactNode;
}
/**
* Stacked label-over-value pair — uppercase dimmed label, value below. Used in
* grids of facts (e.g. an invoice summary, a contract's key figures).
*/
export function Field({ label, value }: FieldProps) {
const isEmpty = value === undefined || value === null || value === "";
return (
<Stack gap={2}>
<Text
size="xs"
fw={600}
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.04em" }}
>
{label}
</Text>
<Text size="sm" c="edr-text">
{isEmpty ? "—" : value}
</Text>
</Stack>
);
}
export interface FieldRowProps {
icon?: LucideIcon;
label: string;
value?: ReactNode;
}
/**
* Left icon+label / right bold value row, divider-separated when stacked in a
* list. Used inside quick-info cards (see `LinkedEntityCard`).
*/
export function FieldRow({ icon: Icon, label, value }: FieldRowProps) {
const isEmpty = value === undefined || value === null || value === "";
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
{Icon ? <Icon size={15} color="var(--mantine-color-gray-5)" /> : null}
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{isEmpty ? "—" : value}
</Text>
</Group>
);
}

View File

@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { Divider, Stack, Text } from "@mantine/core";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { FieldRow, type FieldRowProps } from "./Field";
import { EntityLink } from "./EntityLink";
export interface LinkedEntityCardProps {
icon: LucideIcon;
/** Card title, e.g. "Customer" or "Contract". */
title: string;
/** The entity's own name/reference, rendered as the linked subtitle. */
name: ReactNode;
/** Route to the entity's detail page. Omit when there's nothing to link to
* (e.g. a government booking with no company) — the name renders as plain
* dimmed text instead of a dead link. */
to?: string | null;
accent?: string;
/** Quick-info rows shown below the linked name — empty ones are dropped. */
rows?: FieldRowProps[];
/** Extra content under the rows (e.g. a summary paragraph, an action). */
footer?: ReactNode;
/** Shown instead of rows/footer when there's nothing to display at all. */
emptyMessage?: string;
}
/**
* "Customer at a glance" / "Contract at a glance" card for a detail page's
* sticky rail: a linked title plus a handful of quick-info rows, so the
* related record's essentials are visible without navigating away.
*/
export function LinkedEntityCard({
icon,
title,
name,
to,
accent = "blue",
rows = [],
footer,
emptyMessage,
}: LinkedEntityCardProps) {
const visibleRows = rows.filter((r) => r.value !== undefined && r.value !== null && r.value !== "");
return (
<SectionCard icon={icon} title={title} accent={accent}>
<Stack gap={4}>
<EntityLink to={to} label={name} size="sm" fw={700} />
{visibleRows.length > 0 ? (
<Stack gap={0} mt={4}>
{visibleRows.map((row, index) => (
<div key={row.label}>
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<FieldRow {...row} />
</div>
))}
</Stack>
) : emptyMessage ? (
<Text size="sm" c="dimmed" mt={4}>
{emptyMessage}
</Text>
) : null}
{footer}
</Stack>
</SectionCard>
);
}

View File

@@ -0,0 +1,14 @@
export { Field, FieldRow } from "./Field";
export type { FieldProps, FieldRowProps } from "./Field";
export { EntityLink } from "./EntityLink";
export type { EntityLinkProps } from "./EntityLink";
export { LinkedEntityCard } from "./LinkedEntityCard";
export type { LinkedEntityCardProps } from "./LinkedEntityCard";
// Re-exported so pages under this restructure have one import path for both
// the new quick-info primitives and the existing section-card shell. Imported
// from the file directly (not the bookings/detail barrel) — that barrel also
// re-exports cards that import from this module, and going through it would
// create a circular import.
export { SectionCard } from "@/components/bookings/detail/SectionCard";
export type { SectionCardProps } from "@/components/bookings/detail/SectionCard";

View File

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

View File

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

View File

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

View File

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