mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 01:20:55 +00:00
Merge branch 'dev'
This commit is contained in:
@@ -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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +1,10 @@
|
||||
import { Truck } from "lucide-react";
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { Download, Truck } from "lucide-react";
|
||||
import { Button, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
@@ -8,24 +12,92 @@ 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 and the
|
||||
* stored last-mile contract reference (signed status + PDF download) for
|
||||
* Truck & Machinery once a request on this booking is approved. Renders
|
||||
* nothing when none of the three are present.
|
||||
*/
|
||||
export function BookingMileServicesCard({
|
||||
booking,
|
||||
handoverSection,
|
||||
}: BookingMileServicesCardProps) {
|
||||
const hasAddresses =
|
||||
Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress);
|
||||
|
||||
const { data: requestsResponse } = useQuery({
|
||||
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list({ bookingId: booking.id }),
|
||||
queryFn: async () =>
|
||||
(await lastMileRequestsService.list({ bookingId: booking.id })).data,
|
||||
enabled: Boolean(booking.lastMileDeliveryAddress),
|
||||
});
|
||||
const approvedRequest = (requestsResponse?.data ?? []).find(
|
||||
(r) => r.status === "APPROVED",
|
||||
);
|
||||
|
||||
if (!hasAddresses && !handoverSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const downloadContract = async () => {
|
||||
if (!approvedRequest) return;
|
||||
const blob = (await lastMileRequestsService.contractDocument(approvedRequest.id)).data;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `last-mile-contract-${booking.reference ?? booking.id}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
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} />
|
||||
{approvedRequest && (
|
||||
<Group justify="space-between" align="center" wrap="wrap">
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
Last-mile contract
|
||||
</Text>
|
||||
<Text size="xs" c={approvedRequest.customerSignedAt ? "green.8" : "orange.8"}>
|
||||
{approvedRequest.customerSignedAt
|
||||
? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${
|
||||
approvedRequest.signerDisplayName
|
||||
? ` by ${approvedRequest.signerDisplayName}`
|
||||
: ""
|
||||
}`
|
||||
: "Awaiting customer signature"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => void downloadContract()}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
{handoverSection}
|
||||
</Stack>
|
||||
</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 "./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";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button, Group, TextInput } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { Search, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { getDateRangePresets } from "./dateRangePresets";
|
||||
|
||||
export interface ListControlsProps {
|
||||
search: string;
|
||||
@@ -54,28 +55,19 @@ const ListControls = ({
|
||||
)}
|
||||
|
||||
{showDateRange && (
|
||||
<>
|
||||
<DatePickerInput
|
||||
label={dateLabel ? `${dateLabel} from` : "From"}
|
||||
placeholder="Any"
|
||||
value={dateFrom}
|
||||
onChange={onDateFromChange}
|
||||
// Cannot start after it ends — the picker refuses the invalid range
|
||||
// instead of silently returning nothing.
|
||||
maxDate={dateTo ?? undefined}
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
<DatePickerInput
|
||||
label={dateLabel ? `${dateLabel} to` : "To"}
|
||||
placeholder="Any"
|
||||
value={dateTo}
|
||||
onChange={onDateToChange}
|
||||
minDate={dateFrom ?? undefined}
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
</>
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
label={dateLabel ?? "Date range"}
|
||||
placeholder="Any"
|
||||
value={[dateFrom, dateTo]}
|
||||
onChange={([from, to]) => {
|
||||
onDateFromChange(from);
|
||||
onDateToChange(to);
|
||||
}}
|
||||
presets={getDateRangePresets()}
|
||||
clearable
|
||||
w={230}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
format,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfYear,
|
||||
subDays,
|
||||
subMonths,
|
||||
} from "date-fns";
|
||||
import type { DatePickerPreset } from "@mantine/dates";
|
||||
|
||||
const iso = (date: Date) => format(date, "yyyy-MM-dd");
|
||||
|
||||
/**
|
||||
* Shared "Today / Last 7 days / …" presets for every Mantine
|
||||
* `<DatePickerInput type="range" presets={getDateRangePresets()} />` in the app,
|
||||
* so every from/to filter offers the same shortcuts. Computed fresh per call
|
||||
* (not a module-level constant) so "Today" stays today.
|
||||
*/
|
||||
export function getDateRangePresets(): DatePickerPreset<"range">[] {
|
||||
const today = new Date();
|
||||
return [
|
||||
{ label: "Today", value: [iso(startOfDay(today)), iso(endOfDay(today))] },
|
||||
{
|
||||
label: "Yesterday",
|
||||
value: [iso(startOfDay(subDays(today, 1))), iso(endOfDay(subDays(today, 1)))],
|
||||
},
|
||||
{ label: "Last 7 days", value: [iso(startOfDay(subDays(today, 6))), iso(endOfDay(today))] },
|
||||
{ label: "Last 30 days", value: [iso(startOfDay(subDays(today, 29))), iso(endOfDay(today))] },
|
||||
{ label: "This month", value: [iso(startOfMonth(today)), iso(endOfDay(today))] },
|
||||
{
|
||||
label: "Last month",
|
||||
value: [
|
||||
iso(startOfMonth(subMonths(today, 1))),
|
||||
iso(endOfMonth(subMonths(today, 1))),
|
||||
],
|
||||
},
|
||||
{ label: "Year to date", value: [iso(startOfYear(today)), iso(endOfDay(today))] },
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
|
||||
import { ImageIcon, RefreshCw, X } from "lucide-react";
|
||||
|
||||
const MAX_LOGO_MB = 10;
|
||||
|
||||
export interface LogoUploadProps {
|
||||
/** Logo image as a data URL, or null when none is attached yet. */
|
||||
value: string | null;
|
||||
onChange: (dataUrl: string | null) => void;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Company logo picker — reads the picked image straight into a data URL,
|
||||
* same transport as {@link StampUpload}. Kept as its own component (not a
|
||||
* generalized image-upload) matching how stamp/teeter are already separate
|
||||
* files here despite the near-identical shape.
|
||||
*/
|
||||
export function LogoUpload({
|
||||
value,
|
||||
onChange,
|
||||
label = "Company logo",
|
||||
description = "Attach the official company logo.",
|
||||
}: LogoUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
const readFile = (file: File | undefined | null) => {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("The logo must be an image file (PNG or JPG).");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_LOGO_MB * 1024 * 1024) {
|
||||
setError(`The logo image must be under ${MAX_LOGO_MB} MB.`);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setError(null);
|
||||
setFileName(file.name);
|
||||
onChange(typeof reader.result === "string" ? reader.result : null);
|
||||
};
|
||||
reader.onerror = () => setError("Could not read that file. Try another.");
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const openPicker = () => inputRef.current?.click();
|
||||
|
||||
const clear = () => {
|
||||
setFileName(null);
|
||||
setError(null);
|
||||
onChange(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
hidden
|
||||
onChange={(e) => readFile(e.currentTarget.files?.[0])}
|
||||
/>
|
||||
|
||||
{value ? (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Group gap="md" wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
background:
|
||||
"repeating-conic-gradient(var(--mantine-color-gray-1) 0% 25%, transparent 0% 50%) 50% / 14px 14px",
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
padding: 6,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={value}
|
||||
alt="Company logo"
|
||||
fit="contain"
|
||||
h={92}
|
||||
w={92}
|
||||
/>
|
||||
</Box>
|
||||
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{fileName ?? "Logo attached"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Shown in the header of every generated document.
|
||||
</Text>
|
||||
<Group gap="xs" mt={2}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<RefreshCw size={13} />}
|
||||
onClick={openPicker}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<X size={13} />}
|
||||
onClick={clear}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="lg"
|
||||
onClick={openPicker}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
readFile(e.dataTransfer.files?.[0]);
|
||||
}}
|
||||
style={{
|
||||
borderColor: dragging
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: undefined,
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: dragging
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: undefined,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<ImageIcon size={26} color="var(--mantine-color-edr-green-6)" />
|
||||
<Text size="sm" fw={500}>
|
||||
Upload company logo
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{description} Drop an image here or click to browse — PNG or JPG,
|
||||
up to {MAX_LOGO_MB} MB.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text size="xs" c="red.7">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
type RequestDocumentChangeModalProps,
|
||||
} from "./RequestDocumentChangeModal";
|
||||
export {
|
||||
default as ResetPasswordAction,
|
||||
type ResetPasswordActionProps,
|
||||
} from "./ResetPasswordAction";
|
||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||
export {
|
||||
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";
|
||||
@@ -44,10 +44,12 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/payments",
|
||||
// Invoices, Payments, and USD Payments are tabs on one page now
|
||||
// (FinanceHubPage); the header title itself is set per-tab there.
|
||||
prefix: "/dashboard/invoices",
|
||||
meta: {
|
||||
title: "Payments",
|
||||
subtitle: "View booking payment transactions",
|
||||
title: "Invoices",
|
||||
subtitle: "Invoices, payments, and USD bank transfers",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState } from "react";
|
||||
import { Mail, Loader2 } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Lets the signed-in backoffice user change their own email. Goes through
|
||||
* /me/contact/otp + /me/contact rather than the generic (unverified)
|
||||
* /auth/update-profile route, so the new address is proven before it's
|
||||
* written — see account.controller.ts on the API side.
|
||||
*/
|
||||
export function ChangeEmailCard() {
|
||||
const { user } = useAuth();
|
||||
const sendOtpMutation = useMutation(
|
||||
api.account.sendContactOtp.mutationOptions(),
|
||||
);
|
||||
const updateContactMutation = useMutation(
|
||||
api.account.updateContact.mutationOptions(),
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState<"enterEmail" | "enterOtp">("enterEmail");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
const closeDialog = () => {
|
||||
setOpen(false);
|
||||
setStep("enterEmail");
|
||||
setNewEmail("");
|
||||
setOtp("");
|
||||
setFormError("");
|
||||
};
|
||||
|
||||
const sendOtp = () => {
|
||||
setFormError("");
|
||||
if (!newEmail.trim()) {
|
||||
setFormError("Enter the new email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
sendOtpMutation.mutate(
|
||||
{ channel: "email", value: newEmail.trim() },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Verification code sent to ${result.sentTo}`);
|
||||
setStep("enterOtp");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const confirmOtp = () => {
|
||||
setFormError("");
|
||||
if (!otp.trim()) {
|
||||
setFormError("Enter the verification code.");
|
||||
return;
|
||||
}
|
||||
|
||||
updateContactMutation.mutate(
|
||||
{ channel: "email", value: newEmail.trim(), otp: otp.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Email updated.");
|
||||
closeDialog();
|
||||
// Refetches the session so the new email shows everywhere — simplest
|
||||
// way to refresh the cached user without a dedicated context method.
|
||||
window.location.reload();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="size-4" />
|
||||
Email
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{user?.email ? `Current email: ${user.email}` : "Change your account email."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Change email
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change email</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "enterEmail"
|
||||
? "We'll send a verification code to the new address."
|
||||
: `Enter the code sent to ${newEmail}.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "enterEmail" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newEmail">New email</Label>
|
||||
<Input
|
||||
id="newEmail"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
/>
|
||||
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="otp">Verification code</Label>
|
||||
<Input
|
||||
id="otp"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
/>
|
||||
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
{step === "enterEmail" ? (
|
||||
<Button disabled={sendOtpMutation.isPending} onClick={sendOtp}>
|
||||
{sendOtpMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Send code"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={updateContactMutation.isPending} onClick={confirmOtp}>
|
||||
{updateContactMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Confirm"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from "react";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Lets the signed-in backoffice user change their own password. The account
|
||||
* is logged out on success — the old token was issued under the old
|
||||
* password, and this forces a clean re-login rather than trusting the
|
||||
* server to keep the existing session valid.
|
||||
*/
|
||||
export function ChangePasswordCard() {
|
||||
const { logout } = useAuth();
|
||||
const changePasswordMutation = useMutation(
|
||||
api.account.changePassword.mutationOptions(),
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
const closeDialog = () => {
|
||||
setOpen(false);
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setFormError("");
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setFormError("");
|
||||
|
||||
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||
setFormError("All fields are required.");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setFormError("New password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (newPassword === oldPassword) {
|
||||
setFormError("New password must be different from the current one.");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setFormError("New password and confirmation do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
changePasswordMutation.mutate(
|
||||
{ oldPassword, newPassword, confirmPassword },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Password changed. Please sign in again.");
|
||||
closeDialog();
|
||||
setTimeout(logout, 1200);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-4" />
|
||||
Password
|
||||
</CardTitle>
|
||||
<CardDescription>Change the password for your account.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
Change password
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change password</DialogTitle>
|
||||
<DialogDescription>
|
||||
You'll be signed out and asked to log in again once it's changed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="oldPassword">Current password</Label>
|
||||
<Input
|
||||
id="oldPassword"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">New password</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm new password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={changePasswordMutation.isPending} onClick={submit}>
|
||||
{changePasswordMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Change password"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { overviewChartColors } from "@/components/overview/overview.styles";
|
||||
import type { ReportChartDef, ReportColumn } from "@/types/reports";
|
||||
|
||||
import { formatReportCell } from "./report-format";
|
||||
|
||||
interface ReportChartProps {
|
||||
chart: ReportChartDef;
|
||||
items: Record<string, unknown>[];
|
||||
columns: ReportColumn[];
|
||||
/** Filtered row count on the server. Chart is capped at 100 rows (the API's
|
||||
* page-size ceiling) — surface it plainly rather than silently truncate. */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
const COLORS = overviewChartColors.pipeline;
|
||||
|
||||
/** Plots the same rows the table gets — chart.x/chart.y are just column keys. */
|
||||
export function ReportChart({ chart, items, columns, total }: ReportChartProps) {
|
||||
const columnByKey = new Map(columns.map((c) => [c.key, c]));
|
||||
const yLabel = (key: string) => columnByKey.get(key)?.label ?? key;
|
||||
const yType = (key: string) => columnByKey.get(key)?.type ?? "number";
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No data for the selected filters.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const Chart = chart.type === "line" ? LineChart : BarChart;
|
||||
|
||||
const truncated = typeof total === "number" && total > items.length;
|
||||
|
||||
return (
|
||||
<Box px="md" pb="md">
|
||||
{truncated ? (
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
Showing first {items.length} of {total} rows. Narrow the filters to see the rest charted.
|
||||
</Text>
|
||||
) : null}
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<Chart data={items} margin={{ top: 8, right: 16, left: 0, bottom: 24 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey={chart.x}
|
||||
tick={{ fontSize: 11 }}
|
||||
angle={-20}
|
||||
textAnchor="end"
|
||||
height={50}
|
||||
stroke="#94a3b8"
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||
<Tooltip formatter={(value, name) => [formatReportCell(value, yType(String(name))), yLabel(String(name))]} />
|
||||
{chart.y.length > 1 ? <Legend formatter={(name) => yLabel(String(name))} /> : null}
|
||||
{chart.y.map((key, i) =>
|
||||
chart.type === "line" ? (
|
||||
<Line key={key} type="monotone" dataKey={key} stroke={COLORS[i % COLORS.length]} strokeWidth={2} dot={false} />
|
||||
) : (
|
||||
<Bar key={key} dataKey={key} fill={COLORS[i % COLORS.length]} radius={[4, 4, 0, 0]} />
|
||||
),
|
||||
)}
|
||||
</Chart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportChart;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Button, Checkbox, Group, Modal, Radio, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Download, FileSpreadsheet, FileText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { reportsService } from "@/services/reports.service";
|
||||
import type { ReportCatalogEntry, ReportRunParams } from "@/types/reports";
|
||||
|
||||
interface ReportExportButtonProps {
|
||||
def: ReportCatalogEntry;
|
||||
/** Filters + sort currently applied on screen — no key/page/pageSize. */
|
||||
params: Omit<ReportRunParams, "key" | "page" | "pageSize">;
|
||||
}
|
||||
|
||||
const RECORD_OPTIONS = [
|
||||
{ value: "all", label: "All (up to format limit)" },
|
||||
{ value: "100", label: "First 100" },
|
||||
{ value: "500", label: "First 500" },
|
||||
{ value: "1000", label: "First 1,000" },
|
||||
];
|
||||
|
||||
/** Triggers a browser save for a blob without leaving the SPA. */
|
||||
function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** One export button: format, which fields, how many records — applies the
|
||||
* filters/sort already on screen. Record count defaults to all (capped
|
||||
* server-side per format). */
|
||||
export function ReportExportButton({ def, params }: ReportExportButtonProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [format, setFormat] = useState<"xlsx" | "pdf">("xlsx");
|
||||
const [fields, setFields] = useState<string[]>(def.columns.map((c) => c.key));
|
||||
const [records, setRecords] = useState("all");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const allSelected = fields.length === def.columns.length;
|
||||
const toggleField = (key: string) =>
|
||||
setFields((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||||
const toggleAll = () => setFields(allSelected ? [] : def.columns.map((c) => c.key));
|
||||
|
||||
const handleDownload = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await reportsService.download(def.key, format, {
|
||||
...params,
|
||||
fields: allSelected ? undefined : fields.join(","),
|
||||
limit: records === "all" ? undefined : records,
|
||||
});
|
||||
saveBlob(blob, `${def.key}.${format}`);
|
||||
setOpened(false);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} title="Export report" radius="md" size="md">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Format
|
||||
</Text>
|
||||
<Radio.Group value={format} onChange={(v) => setFormat(v as "xlsx" | "pdf")}>
|
||||
<SimpleGrid cols={2}>
|
||||
<Radio.Card value="xlsx" radius="md" p="md">
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Radio.Indicator />
|
||||
<FileSpreadsheet size={22} />
|
||||
<Text size="sm" fw={500}>
|
||||
Excel (.xlsx)
|
||||
</Text>
|
||||
</Group>
|
||||
</Radio.Card>
|
||||
<Radio.Card value="pdf" radius="md" p="md">
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Radio.Indicator />
|
||||
<FileText size={22} />
|
||||
<Text size="sm" fw={500}>
|
||||
PDF
|
||||
</Text>
|
||||
</Group>
|
||||
</Radio.Card>
|
||||
</SimpleGrid>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Fields
|
||||
</Text>
|
||||
<Button variant="subtle" size="compact-sm" onClick={toggleAll}>
|
||||
{allSelected ? "Clear all" : "Select all"}
|
||||
</Button>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{def.columns.map((col) => (
|
||||
<Checkbox
|
||||
key={col.key}
|
||||
label={col.label}
|
||||
checked={fields.includes(col.key)}
|
||||
onChange={() => toggleField(col.key)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Records"
|
||||
value={records}
|
||||
onChange={(v) => setRecords(v ?? "all")}
|
||||
data={RECORD_OPTIONS}
|
||||
allowDeselect={false}
|
||||
radius="md"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Uses the filters and sorting currently applied to the report.
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" radius="md" onClick={() => setOpened(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
radius="md"
|
||||
loading={exporting}
|
||||
disabled={!fields.length}
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportExportButton;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
|
||||
import { DateInput, DatePickerInput } from "@mantine/dates";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||
import type { ReportFilterDef } from "@/types/reports";
|
||||
|
||||
export interface ReportFilterValues {
|
||||
[param: string]: string | undefined;
|
||||
}
|
||||
|
||||
interface ReportFiltersProps {
|
||||
filters: ReportFilterDef[];
|
||||
values: ReportFilterValues;
|
||||
onChange: (values: ReportFilterValues) => void;
|
||||
}
|
||||
|
||||
const toDate = (value: string | undefined): Date | null => (value ? new Date(value) : null);
|
||||
const fromDate = (value: string | null): string | undefined => value ?? undefined;
|
||||
|
||||
/** Renders one widget per report-declared filter and reports raw param values back up. */
|
||||
export function ReportFilters({ filters, values, onChange }: ReportFiltersProps) {
|
||||
if (!filters.length) return null;
|
||||
|
||||
const set = (patch: ReportFilterValues) => onChange({ ...values, ...patch });
|
||||
|
||||
return (
|
||||
<Group gap="sm" wrap="wrap">
|
||||
{filters.map((filter) => {
|
||||
switch (filter.type) {
|
||||
case "daterange":
|
||||
return (
|
||||
<DatePickerInput
|
||||
key={filter.key}
|
||||
type="range"
|
||||
placeholder={filter.label}
|
||||
value={[values[`${filter.key}From`] ?? null, values[`${filter.key}To`] ?? null]}
|
||||
onChange={([from, to]) =>
|
||||
set({ [`${filter.key}From`]: fromDate(from), [`${filter.key}To`]: fromDate(to) })
|
||||
}
|
||||
presets={getDateRangePresets()}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={230}
|
||||
/>
|
||||
);
|
||||
case "date":
|
||||
return (
|
||||
<DateInput
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
value={toDate(values[filter.key])}
|
||||
onChange={(d) => set({ [filter.key]: fromDate(d) })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
data={filter.options ?? []}
|
||||
value={values[filter.key] ?? null}
|
||||
onChange={(v) => set({ [filter.key]: v ?? undefined })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={170}
|
||||
/>
|
||||
);
|
||||
case "multiselect":
|
||||
return (
|
||||
<MultiSelect
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
data={filter.options ?? []}
|
||||
value={values[filter.key]?.split(",").filter(Boolean) ?? []}
|
||||
onChange={(v) => set({ [filter.key]: v.length ? v.join(",") : undefined })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<TextInput
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
leftSection={<Search size={16} />}
|
||||
value={values[filter.key] ?? ""}
|
||||
onChange={(e) => set({ [filter.key]: e.target.value || undefined })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={220}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportFilters;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Stack, Text, Title } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
|
||||
import { ReportView } from "./ReportView";
|
||||
|
||||
interface ReportSectionProps {
|
||||
reportKey: string;
|
||||
/** Scopes the report to one entity, e.g. the contract this page is showing. */
|
||||
idKeyValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a report inline on any page — a contract detail page embedding
|
||||
* `contract-utilization`, for instance. Renders nothing while the catalog is
|
||||
* loading or if the caller lacks the report's permission, so pages can embed
|
||||
* it unconditionally without their own permission check.
|
||||
*/
|
||||
export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) {
|
||||
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
|
||||
const def = catalog?.find((r) => r.key === reportKey);
|
||||
|
||||
if (!def) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<div>
|
||||
<Title order={4}>{def.title}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{def.description}
|
||||
</Text>
|
||||
</div>
|
||||
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportSection;
|
||||
@@ -0,0 +1,226 @@
|
||||
import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Column, SortingState } from "@tanstack/react-table";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { PageHeader } from "@/components/page";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { api } from "@/services/api";
|
||||
import type { ReportRunParams } from "@/types/reports";
|
||||
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { ReportChart } from "./ReportChart";
|
||||
import { ReportExportButton } from "./ReportExportButton";
|
||||
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
|
||||
import { formatKpiValue, formatReportCell } from "./report-format";
|
||||
|
||||
function SortableHeader({ label, column }: { label: string; column: Column<Record<string, unknown>, unknown> }) {
|
||||
const sorted = column.getIsSorted();
|
||||
const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{label}
|
||||
</Text>
|
||||
<Icon size={13} opacity={sorted ? 1 : 0.4} />
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReportViewProps {
|
||||
reportKey: string;
|
||||
/** Scopes the report to one entity when embedded (e.g. a contract detail page). */
|
||||
idKeyValue?: string;
|
||||
/** Full-page usage: renders the title/description as a PageHeader (no back
|
||||
* arrow) with export/refresh as its actions, instead of inline above the
|
||||
* table. Off by default for embedded sections. */
|
||||
pageHeader?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The report engine: one component renders any report the catalog describes —
|
||||
* filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export.
|
||||
* Adding a report never touches this file.
|
||||
*/
|
||||
export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProps) {
|
||||
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
|
||||
const def = catalog?.find((r) => r.key === reportKey);
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 20 });
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
|
||||
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
|
||||
const [view, setView] = useState<"table" | "chart">("table");
|
||||
|
||||
// Filters + sort as the user currently has them — independent of the view
|
||||
// toggle's paging, so export always matches what's on screen either way.
|
||||
const appliedParams = useMemo(() => {
|
||||
const sort = sorting[0];
|
||||
return {
|
||||
sortBy: sort?.id,
|
||||
sortOrder: sort ? (sort.desc ? "DESC" as const : "ASC" as const) : undefined,
|
||||
...debouncedFilters,
|
||||
...(def?.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
|
||||
};
|
||||
}, [def, sorting, debouncedFilters, idKeyValue]);
|
||||
|
||||
const runParams: ReportRunParams | undefined = useMemo(() => {
|
||||
if (!def) return undefined;
|
||||
return {
|
||||
key: def.key,
|
||||
// Chart view isn't paginated on screen — pull the server's max page (100)
|
||||
// in one shot instead of just whatever page the table happens to be on,
|
||||
// so the chart doesn't silently plot a fraction of the filtered rows.
|
||||
page: view === "chart" ? 1 : pagination.pageIndex + 1,
|
||||
pageSize: view === "chart" ? 100 : pagination.pageSize,
|
||||
...appliedParams,
|
||||
};
|
||||
}, [def, view, pagination, appliedParams]);
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
...api.reports.run.queryOptions({ input: runParams as ReportRunParams }),
|
||||
enabled: Boolean(runParams),
|
||||
});
|
||||
|
||||
const total = data?.meta.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const columns: ColumnDef<Record<string, unknown>>[] = useMemo(
|
||||
() =>
|
||||
(def?.columns ?? []).map((col) => ({
|
||||
id: col.key,
|
||||
accessorKey: col.key,
|
||||
header: col.sortable
|
||||
? ({ column }) => <SortableHeader label={col.label} column={column} />
|
||||
: col.label,
|
||||
enableSorting: col.sortable,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text">
|
||||
{formatReportCell(row.original[col.key], col.type)}
|
||||
</Text>
|
||||
),
|
||||
})),
|
||||
[def?.columns],
|
||||
);
|
||||
|
||||
if (!def) {
|
||||
return catalog ? (
|
||||
<Alert color="red">You don't have access to this report.</Alert>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const chartToggle = def.chart ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={view}
|
||||
onChange={(v) => setView(v as "table" | "chart")}
|
||||
data={[
|
||||
{ label: <LayoutGrid size={14} />, value: "table" },
|
||||
{ label: <LineChart size={14} />, value: "chart" },
|
||||
]}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const refreshButton = (
|
||||
<Tooltip label="Refresh">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius="md"
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const exportButton = <ReportExportButton def={def} params={appliedParams} />;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{pageHeader ? (
|
||||
<PageHeader
|
||||
title={def.title}
|
||||
subtitle={def.description}
|
||||
action={
|
||||
<Group gap="xs">
|
||||
{exportButton}
|
||||
{refreshButton}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data?.kpis.length ? (
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={data.kpis.map((k) => ({ label: k.label, value: formatKpiValue(k.value, k.unit) }))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<ReportFilters
|
||||
filters={def.filters}
|
||||
values={filterValues}
|
||||
onChange={(v) => {
|
||||
setFilterValues(v);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
{chartToggle}
|
||||
{pageHeader ? null : (
|
||||
<>
|
||||
{exportButton}
|
||||
{refreshButton}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{view === "chart" && def.chart ? (
|
||||
<ReportChart chart={def.chart} items={data?.items ?? []} columns={def.columns} total={total} />
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No data for the selected filters."
|
||||
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportView;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReportColumnType } from "@/types/reports";
|
||||
|
||||
/** Cell formatting shared by the on-screen table and (indirectly) exports. */
|
||||
export function formatReportCell(value: unknown, type: ReportColumnType): string {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
switch (type) {
|
||||
case "money":
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: "ETB",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(Number(value));
|
||||
case "tons":
|
||||
return `${Number(value).toLocaleString()} t`;
|
||||
case "percent":
|
||||
return `${value}%`;
|
||||
case "number":
|
||||
return Number(value).toLocaleString();
|
||||
case "date": {
|
||||
const d = new Date(String(value));
|
||||
return Number.isNaN(d.getTime())
|
||||
? String(value)
|
||||
: d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatKpiValue(value: number, unit?: string): string {
|
||||
const formatted = value.toLocaleString(undefined, { maximumFractionDigits: 1 });
|
||||
if (unit === "ETB") {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: "ETB",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
if (unit === "%") return `${formatted}%`;
|
||||
if (unit === "t") return `${formatted} t`;
|
||||
return formatted;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
// Repeat, // used by the hidden Move (reassign) button
|
||||
Train,
|
||||
TrainFront,
|
||||
Truck,
|
||||
Weight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -36,7 +37,9 @@ import {
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import { api } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
EligibleContainerBooking,
|
||||
@@ -411,6 +414,31 @@ export function ScheduleWorkspacePanel({
|
||||
);
|
||||
};
|
||||
|
||||
// Export cargo that skipped the warehouse (customer truck straight onto the
|
||||
// wagon) has no GRN and never will — loadBooking's GRN gate would keep
|
||||
// rejecting it forever. Setting DIRECT_TO_TRAIN tells that gate the carriage
|
||||
// acceptance sheet is the handover document instead, then loads in one click.
|
||||
const [truckToTrainPending, setTruckToTrainPending] = useState<string | null>(null);
|
||||
const doTruckToTrain = (bookingId: string, ref: string) => {
|
||||
setTruckToTrainPending(bookingId);
|
||||
bookingsService
|
||||
.setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN")
|
||||
.then(() => loadJourney.mutateAsync({ scheduleId: schedule.id, bookingId }))
|
||||
.then(() => {
|
||||
toast({ title: `${ref} loaded — direct truck-to-train handover` });
|
||||
onChanged();
|
||||
void yardWorkQuery.refetch();
|
||||
})
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not load as direct truck-to-train",
|
||||
description: apiErrorMessage(error, "Please try again."),
|
||||
variant: "destructive",
|
||||
}),
|
||||
)
|
||||
.finally(() => setTruckToTrainPending(null));
|
||||
};
|
||||
|
||||
const doUnload = (bookingId: string, ref: string) => {
|
||||
unloadJourney
|
||||
.mutateAsync({ scheduleId: schedule.id, bookingId })
|
||||
@@ -601,6 +629,7 @@ export function ScheduleWorkspacePanel({
|
||||
{pool.map((b) => (
|
||||
<BookingCard
|
||||
key={b.id}
|
||||
bookingId={b.id}
|
||||
reference={b.reference}
|
||||
customer={b.customer}
|
||||
weightTons={b.weightTons}
|
||||
@@ -708,9 +737,16 @@ export function ScheduleWorkspacePanel({
|
||||
const alightHere = trainAtYardId != null && b.destinationYardId === trainAtYardId;
|
||||
const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false);
|
||||
const showUnload = canWork && riding && (journey?.canUnload ?? false);
|
||||
const showTruckToTrain =
|
||||
canWork &&
|
||||
!riding &&
|
||||
!done &&
|
||||
boardHere &&
|
||||
b.tradeDirection === "EXPORT";
|
||||
return (
|
||||
<BookingCard
|
||||
key={b.id}
|
||||
bookingId={b.id}
|
||||
reference={ref}
|
||||
customer={b.customer}
|
||||
weightTons={b.weightTons}
|
||||
@@ -764,6 +800,24 @@ export function ScheduleWorkspacePanel({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showTruckToTrain ? (
|
||||
<Tooltip
|
||||
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="md"
|
||||
leftSection={<Truck size={13} />}
|
||||
loading={truckToTrainPending === b.id}
|
||||
onClick={() => doTruckToTrain(b.id, ref)}
|
||||
>
|
||||
Truck to Train
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showUnload ? (
|
||||
<Tooltip
|
||||
label={
|
||||
@@ -985,6 +1039,7 @@ function PanelColumn({
|
||||
}
|
||||
|
||||
function BookingCard({
|
||||
bookingId,
|
||||
reference,
|
||||
customer,
|
||||
weightTons,
|
||||
@@ -996,6 +1051,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 +1087,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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user