style: ui fixes

This commit is contained in:
Nathnael
2026-08-13 13:46:46 +00:00
parent 0df4be1820
commit 89cdc0ad06
46 changed files with 417 additions and 648 deletions

View File

@@ -1,5 +1,5 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react"; import { ExternalLink, MoreHorizontal } from "lucide-react";
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core"; import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { BookingConfirmDialog } from "./BookingConfirmDialog";
@@ -64,17 +64,9 @@ export function BookingActionsMenu({
const hasMenu = listRowHasActions(row, user); const hasMenu = listRowHasActions(row, user);
// Row click already opens the detail page — no chevron affordance needed.
if (!hasMenu && variant === "table") { if (!hasMenu && variant === "table") {
return ( return null;
<ActionIcon
variant="subtle"
color="gray"
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
aria-label="View booking"
>
<ChevronRight size={16} />
</ActionIcon>
);
} }
// Toolbar: lay every action out as a button row. // Toolbar: lay every action out as a button row.

View File

@@ -1,6 +1,7 @@
import { Badge, Group } from "@mantine/core"; import { Badge, Group } from "@mantine/core";
import { Link2 } from "lucide-react"; import { Link2 } from "lucide-react";
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config"; import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
import { humanize } from "@/lib/format";
const statusColorMap: Record<string, string> = { const statusColorMap: Record<string, string> = {
DRAFT: "gray", DRAFT: "gray",
@@ -40,7 +41,7 @@ export function BookingStatusBadge({
partnerReference, partnerReference,
}: BookingStatusBadgeProps) { }: BookingStatusBadgeProps) {
const style = BOOKING_STATUS_STYLES[status] ?? { const style = BOOKING_STATUS_STYLES[status] ?? {
label: status, label: humanize(status),
color: "gray", color: "gray",
}; };
const color = statusColorMap[status] ?? "gray"; const color = statusColorMap[status] ?? "gray";

View File

@@ -0,0 +1,34 @@
import { ActionIcon, Indicator } from "@mantine/core";
import { Filter } from "lucide-react";
export interface FilterToggleProps {
/** Number of active advanced filters — shown as a badge on the button. */
count: number;
expanded: boolean;
onClick: () => void;
}
/** Toggle for the collapsible advanced-filters row on list pages. */
export function FilterToggle({ count, expanded, onClick }: FilterToggleProps) {
return (
<Indicator
label={count}
size={16}
color="edr-green"
disabled={count === 0}
offset={4}
>
<ActionIcon
variant={expanded ? "filled" : "default"}
color="edr-green"
size="lg"
radius="lg"
aria-label="Toggle advanced filters"
aria-expanded={expanded}
onClick={onClick}
>
<Filter size={16} />
</ActionIcon>
</Indicator>
);
}

View File

@@ -10,6 +10,7 @@ import {
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core"; import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { formatDate } from "@/lib/format";
import { import {
CONTRACT_APPROVAL_ROLE_LABELS, CONTRACT_APPROVAL_ROLE_LABELS,
HAZARDOUS_APPROVAL_ROLE_PERMISSION, HAZARDOUS_APPROVAL_ROLE_PERMISSION,
@@ -54,10 +55,6 @@ function formatAgo(iso: string): string {
return "just now"; return "just now";
} }
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
}
type MilestoneIcon = typeof Send; type MilestoneIcon = typeof Send;
interface Milestone { interface Milestone {

View File

@@ -1,92 +0,0 @@
import { Badge, ScrollArea, Tabs } from "@mantine/core";
import {
ClipboardCheck,
FileSignature,
Inbox,
LayoutGrid,
ShieldCheck,
Truck,
XCircle,
} from "lucide-react";
import "@/components/overview/overview.css";
import {
CONTRACT_LIST_TABS,
type ContractStatusTabKey,
} from "@/features/contracts/contract-status.config";
const TAB_ICONS: Record<ContractStatusTabKey, React.ReactNode> = {
all: <LayoutGrid size={17} strokeWidth={1.85} />,
intake: <Inbox size={17} strokeWidth={1.85} />,
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
clearance: <ShieldCheck size={17} strokeWidth={1.85} />,
active: <Truck size={17} strokeWidth={1.85} />,
closed: <XCircle size={17} strokeWidth={1.85} />,
};
interface ContractStatusTabsProps {
active: ContractStatusTabKey;
onChange: (tab: ContractStatusTabKey) => void;
counts?: Partial<Record<ContractStatusTabKey, number>>;
}
export function ContractStatusTabs({
active,
onChange,
counts,
}: ContractStatusTabsProps) {
return (
<Tabs
value={active}
onChange={(value) => onChange((value as ContractStatusTabKey) ?? "all")}
variant="pills"
color="edr-green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
{CONTRACT_LIST_TABS.map((tab) => {
const isActive = active === tab.key;
const count = counts?.[tab.key];
return (
<Tabs.Tab
key={tab.key}
value={tab.key}
leftSection={TAB_ICONS[tab.key]}
size={"sm"}
rightSection={
count !== undefined ? (
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "edr-green" : "gray"}
styles={
isActive
? {
root: {
background: "rgba(255,255,255,0.9)",
color: "#15805f",
},
}
: undefined
}
>
{count}
</Badge>
) : undefined
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</ScrollArea>
</Tabs>
);
}
export type { ContractStatusTabKey };

View File

@@ -116,8 +116,9 @@ export function CompanyNationalityBadge({
/** /**
* Profile chips for a company row: one chip per role (Importer / Exporter / …) * Profile chips for a company row: one chip per role (Importer / Exporter / …)
* carrying its reference code. Caps at three (a company has at most three * carrying its reference code, colored by the profile's status (green active,
* profiles); any extra collapse into a `+N` chip. * amber pending, red rejected/blacklisted). Caps at three (a company has at
* most three profiles); any extra collapse into a `+N` chip.
*/ */
export function ProfileChips({ export function ProfileChips({
profiles, profiles,
@@ -152,14 +153,15 @@ export function ProfileChips({
withArrow withArrow
> >
<Badge <Badge
color={PROFILE_TYPE_COLOR[profile.type] ?? "gray"} color={STATUS_COLOR[profile.status] ?? "gray"}
variant="light" variant="light"
size="sm" size="sm"
radius="md" radius="md"
fw={600} fw={600}
style={badgeStyle} style={badgeStyle}
> >
{humanize(profile.type)} · {profile.reference} {humanize(profile.type)}
{profile.reference ? ` · ${profile.reference}` : ""}
</Badge> </Badge>
</Tooltip> </Tooltip>
))} ))}

View File

@@ -1,38 +1,2 @@
/** Shared formatting helpers for the customer-management pages. */ /** @deprecated import from "@/lib/format" (or ../../lib/format) instead. */
export { humanize, formatDate, formatDateTime, formatMoney, formatBytes } from "../../lib/format";
/** snake_case / SCREAMING_CASE → Title Case. */
export function humanize(value: string): string {
return value
.toLowerCase()
.split(/[_\s]+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function formatMoney(amount: number, currency: string): string {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(amount);
}
export function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}

View File

@@ -26,7 +26,7 @@ interface OverviewActivityHeatmapProps {
* selected range. The bright cells (and the peak badge) are the hours the * selected range. The bright cells (and the peak badge) are the hours the
* intake team needs to be staffed for. * intake team needs to be staffed for.
*/ */
export function OverviewActivityHeatmap({ cells }: OverviewActivityHeatmapProps) { export function OverviewActivityHeatmap({ cells = [] }: OverviewActivityHeatmapProps) {
const countByCell = new Map(cells.map((c) => [`${c.dow}-${c.block}`, c.count])); const countByCell = new Map(cells.map((c) => [`${c.dow}-${c.block}`, c.count]));
const max = Math.max(0, ...cells.map((c) => c.count)); const max = Math.max(0, ...cells.map((c) => c.count));
const peak = cells.reduce<IOverviewHeatmapCell | null>( const peak = cells.reduce<IOverviewHeatmapCell | null>(

View File

@@ -30,35 +30,35 @@ export function OverviewAttentionCard({ bookings, contracts, billing }: Overview
{ {
key: "needsAction", key: "needsAction",
label: "Bookings needing action", label: "Bookings needing action",
count: bookings.needsAction, count: bookings.needsAction ?? 0,
icon: AlertCircle, icon: AlertCircle,
href: "/dashboard/booking-requests", href: "/dashboard/booking-requests",
}, },
{ {
key: "urgent", key: "urgent",
label: "Urgent bookings", label: "Urgent bookings",
count: bookings.urgent, count: bookings.urgent ?? 0,
icon: Clock, icon: Clock,
href: "/dashboard/booking-requests", href: "/dashboard/booking-requests",
}, },
{ {
key: "contractsApproval", key: "contractsApproval",
label: "Contracts in approval", label: "Contracts in approval",
count: contracts.inApproval, count: contracts.inApproval ?? 0,
icon: FileSignature, icon: FileSignature,
href: "/dashboard/contract-requests", href: "/dashboard/contract-requests",
}, },
{ {
key: "contractsClearance", key: "contractsClearance",
label: "Contracts in clearance", label: "Contracts in clearance",
count: contracts.inClearance, count: contracts.inClearance ?? 0,
icon: ShieldCheck, icon: ShieldCheck,
href: "/dashboard/contracts/clearance", href: "/dashboard/contracts/clearance",
}, },
{ {
key: "pendingPayments", key: "pendingPayments",
label: "Pending payments", label: "Pending payments",
count: billing.pendingPayments, count: billing.pendingPayments ?? 0,
icon: Banknote, icon: Banknote,
href: "/dashboard/payments", href: "/dashboard/payments",
}, },

View File

@@ -2,6 +2,7 @@ import { RefreshCw } from "lucide-react";
import { ActionIcon, Badge, Group, SegmentedControl, Stack, Text } from "@mantine/core"; import { ActionIcon, Badge, Group, SegmentedControl, Stack, Text } from "@mantine/core";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { formatDateTime } from "@/lib/format";
import { freightBrand } from "@/theme/freight-brand"; import { freightBrand } from "@/theme/freight-brand";
import type { OverviewRange } from "@/types/overview"; import type { OverviewRange } from "@/types/overview";
import "@/components/overview/overview.css"; import "@/components/overview/overview.css";
@@ -20,7 +21,7 @@ function formatRelativeTime(iso: string | undefined) {
if (minutes < 60) return `${minutes}m ago`; if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`; if (hours < 24) return `${hours}h ago`;
return new Date(iso).toLocaleString(); return formatDateTime(iso);
} }
function greeting(hour: number) { function greeting(hour: number) {

View File

@@ -34,21 +34,27 @@ interface OverviewHeroKpisProps {
*/ */
export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: OverviewHeroKpisProps) { export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: OverviewHeroKpisProps) {
const items: KpiItem[] = [ const items: KpiItem[] = [
{ // An API deployed before the overview revamp omits the period totals —
label: `Revenue (${rangeLabel})`, // drop the two tiles that need them rather than crash (or hide the strip).
value: <CountUp value={current.revenueEtb} format={(n) => formatCurrency(n, "ETB")} />, ...(current
hint: formatCurrency(current.revenueUsd, "USD"), ? [
icon: Banknote, {
color: "yellow", label: `Revenue (${rangeLabel})`,
delta: pctDelta(current.revenueEtb, previous.revenueEtb), value: <CountUp value={current.revenueEtb} format={(n) => formatCurrency(n, "ETB")} />,
}, hint: formatCurrency(current.revenueUsd, "USD"),
{ icon: Banknote,
label: "Cargo moved", color: "yellow",
value: <CountUp value={current.tons} format={(n) => `${Math.round(n).toLocaleString()} t`} />, delta: pctDelta(current.revenueEtb, previous?.revenueEtb ?? 0),
icon: Package, },
color: "edr-green", {
delta: pctDelta(current.tons, previous.tons), label: "Cargo moved",
}, value: <CountUp value={current.tons} format={(n) => `${Math.round(n).toLocaleString()} t`} />,
icon: Package,
color: "edr-green",
delta: pctDelta(current.tons, previous?.tons ?? 0),
},
]
: []),
{ {
label: "Active bookings", label: "Active bookings",
value: <CountUp value={kpis.bookings.totalActive} />, value: <CountUp value={kpis.bookings.totalActive} />,

View File

@@ -25,7 +25,10 @@ const STATS: Array<{
/** Network snapshot: four operational stats plus real wagon-utilization (available / total), not a decorative gauge. */ /** Network snapshot: four operational stats plus real wagon-utilization (available / total), not a decorative gauge. */
export function OverviewNetworkCard({ kpis }: { kpis: IOverviewOperationsKpis }) { export function OverviewNetworkCard({ kpis }: { kpis: IOverviewOperationsKpis }) {
const utilizationPct = kpis.wagonsTotal > 0 ? (kpis.wagonsAvailable / kpis.wagonsTotal) * 100 : null; // An API deployed before the overview revamp omits the wagon counters.
const wagonsTotal = kpis.wagonsTotal ?? 0;
const wagonsAvailable = kpis.wagonsAvailable ?? 0;
const utilizationPct = wagonsTotal > 0 ? (wagonsAvailable / wagonsTotal) * 100 : null;
return ( return (
<SummaryCard <SummaryCard
@@ -52,10 +55,10 @@ export function OverviewNetworkCard({ kpis }: { kpis: IOverviewOperationsKpis })
Wagons available Wagons available
</Text> </Text>
<Text fw={800} fz={22} lh={1.1}> <Text fw={800} fz={22} lh={1.1}>
{kpis.wagonsAvailable.toLocaleString()} {wagonsAvailable.toLocaleString()}
<Text component="span" fz="sm" fw={600} c="dimmed"> <Text component="span" fz="sm" fw={600} c="dimmed">
{" "} {" "}
/ {kpis.wagonsTotal.toLocaleString()} / {wagonsTotal.toLocaleString()}
</Text> </Text>
</Text> </Text>
</Stack> </Stack>
@@ -72,7 +75,7 @@ export function OverviewNetworkCard({ kpis }: { kpis: IOverviewOperationsKpis })
/> />
<Stack gap={0} style={{ minWidth: 0 }}> <Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={800} fz="lg" lh={1.15}> <Text fw={800} fz="lg" lh={1.15}>
<CountUp value={kpis[stat.key]} /> <CountUp value={kpis[stat.key] ?? 0} />
</Text> </Text>
<Text size="xs" c="dimmed" truncate> <Text size="xs" c="dimmed" truncate>
{stat.label} {stat.label}

View File

@@ -14,7 +14,7 @@ interface OverviewPipelineFunnelProps {
} }
/** Booking pipeline by stage, in workflow order. Each row deep-links to the exact statuses it represents. */ /** Booking pipeline by stage, in workflow order. Each row deep-links to the exact statuses it represents. */
export function OverviewPipelineFunnel({ data }: OverviewPipelineFunnelProps) { export function OverviewPipelineFunnel({ data = [] }: OverviewPipelineFunnelProps) {
const rows = data const rows = data
.map((item) => ({ .map((item) => ({
...item, ...item,

View File

@@ -111,7 +111,7 @@ interface OverviewRevenueMixProps {
} }
/** ETB revenue split two ways — trade direction and freight type — anchored by the range total. */ /** ETB revenue split two ways — trade direction and freight type — anchored by the range total. */
export function OverviewRevenueMix({ byDirection, byFreightType }: OverviewRevenueMixProps) { export function OverviewRevenueMix({ byDirection = [], byFreightType = [] }: OverviewRevenueMixProps) {
const total = byDirection.reduce((sum, s) => sum + s.amountEtb, 0); const total = byDirection.reduce((sum, s) => sum + s.amountEtb, 0);
return ( return (

View File

@@ -67,9 +67,9 @@ interface OverviewRevenueVolumeChartProps {
* period" at a glance. * period" at a glance.
*/ */
export function OverviewRevenueVolumeChart({ export function OverviewRevenueVolumeChart({
bookingTrend, bookingTrend = [],
paymentTrend, paymentTrend = [],
previousPaymentTrend, previousPaymentTrend = [],
rangeDays, rangeDays,
}: OverviewRevenueVolumeChartProps) { }: OverviewRevenueVolumeChartProps) {
const data = mergeTrend(bookingTrend, paymentTrend, previousPaymentTrend, rangeDays); const data = mergeTrend(bookingTrend, paymentTrend, previousPaymentTrend, rangeDays);

View File

@@ -135,7 +135,7 @@ interface OverviewSankeyFlowProps {
* freight type. Ribbon thickness is proportional to revenue, so the biggest * freight type. Ribbon thickness is proportional to revenue, so the biggest
* corridor is unmissable. * corridor is unmissable.
*/ */
export function OverviewSankeyFlow({ flows }: OverviewSankeyFlowProps) { export function OverviewSankeyFlow({ flows = [] }: OverviewSankeyFlowProps) {
const data = toSankeyData(flows); const data = toSankeyData(flows);
return ( return (

View File

@@ -99,18 +99,8 @@ export const formatDays = (value: number | null | undefined) => {
return `${rounded} ${rounded === 1 ? 'day' : 'days'}`; return `${rounded} ${rounded === 1 ? 'day' : 'days'}`;
}; };
export const formatDate = (value: string | null | undefined) => { // Despite the name, this has always rendered date + time — hence formatDateTime.
if (!value) return '—'; export { formatDateTime as formatDate } from '@/lib/format';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers. // Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers.
export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, ''); export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, '');

View File

@@ -0,0 +1,62 @@
/** Shared display-formatting helpers for the backoffice. */
/** snake_case / SCREAMING_CASE → Title Case. */
export function humanize(value: string): string {
return value
.toLowerCase()
.split(/[_\s]+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function formatDateTime(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/** Pass `fractionDigits` where cents matter; the default matches the legacy whole-figure display. */
export function formatMoney(
amount: number,
currency: string,
fractionDigits?: number,
): string {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
...(fractionDigits === undefined
? { maximumFractionDigits: 0 }
: {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}),
}).format(amount);
}
export function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}

View File

@@ -61,6 +61,7 @@ import {
import { WarehouseInfoCard } from "@/components/warehouses"; import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { formatDateTime, formatMoney } from "@/lib/format";
import { cargoTonsAndItems } from "@/utils/cargoWeight"; import { cargoTonsAndItems } from "@/utils/cargoWeight";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { import {
@@ -186,7 +187,7 @@ export default function BookingRequestDetailPage() {
const kpis: KpiItem[] = [ const kpis: KpiItem[] = [
{ {
label: "Total value", label: "Total value",
value: `${booking.paymentCurrency} ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, value: formatMoney(amount, booking.paymentCurrency, 2),
hint: booking.paymentStatus, hint: booking.paymentStatus,
icon: Wallet, icon: Wallet,
color: "edr-green", color: "edr-green",
@@ -326,7 +327,7 @@ export default function BookingRequestDetailPage() {
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="orange.7"> <Text size="xs" c="orange.7">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} Hold expires {formatDateTime(booking.holdExpiresAt)}
</Text> </Text>
) : null} ) : null}

View File

@@ -5,6 +5,7 @@ import {
Button, Button,
Card, Card,
Checkbox, Checkbox,
Collapse,
Group, Group,
Modal, Modal,
MultiSelect, MultiSelect,
@@ -36,6 +37,8 @@ import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { FilterToggle } from "@/components/common/FilterToggle";
import { formatDate, humanize } from "@/lib/format";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. // BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
@@ -50,7 +53,6 @@ import {
useBookingList, useBookingList,
useBookingListSummary, useBookingListSummary,
} from "@/hooks/bookings/useBookings"; } from "@/hooks/bookings/useBookings";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service"; import type { BookingListFilter } from "@/services/bookings.service";
import { trainSchedulingService } from "@/services/trainScheduling.service"; import { trainSchedulingService } from "@/services/trainScheduling.service";
@@ -116,18 +118,6 @@ function endOfDayIso(d: Date): string {
return x.toISOString(); return x.toISOString();
} }
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function BookingRequestsPage() { export default function BookingRequestsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) — // Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
@@ -159,6 +149,11 @@ export default function BookingRequestsPage() {
const [createdTo, setCreatedTo] = useState<Date | null>(null); const [createdTo, setCreatedTo] = useState<Date | null>(null);
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null); const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
const [scheduledTo, setScheduledTo] = useState<Date | null>(null); const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
// Direction is the only deep-linkable advanced filter — open the panel so a
// deep link never hides its own filter.
const [showAdvanced, setShowAdvanced] = useState(() =>
Boolean(paramDirection),
);
const [allocateOpen, setAllocateOpen] = useState(false); const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]); const [allocateIds, setAllocateIds] = useState<string[]>([]);
// Paid bookings with no train attached (staff removed them or a sweep // Paid bookings with no train attached (staff removed them or a sweep
@@ -185,6 +180,7 @@ export default function BookingRequestsPage() {
const next = paramStatuses.split(",").filter(Boolean); const next = paramStatuses.split(",").filter(Boolean);
setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next)); setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next));
setDirectionFilter(paramDirection); setDirectionFilter(paramDirection);
if (paramDirection) setShowAdvanced(true);
}, [paramStatuses, paramDirection]); }, [paramStatuses, paramDirection]);
const filter: BookingListFilter = useMemo(() => { const filter: BookingListFilter = useMemo(() => {
@@ -278,6 +274,10 @@ export default function BookingRequestsPage() {
(createdFrom || createdTo ? 1 : 0) + (createdFrom || createdTo ? 1 : 0) +
(scheduledFrom || scheduledTo ? 1 : 0); (scheduledFrom || scheduledTo ? 1 : 0);
// Badge on the advanced-filters toggle — active filters hidden behind it.
const advancedFilterCount =
activeFilterCount - (kindFilter ? 1 : 0) - (statusFilter.length ? 1 : 0);
const clearFilters = useCallback(() => { const clearFilters = useCallback(() => {
setKindFilter(null); setKindFilter(null);
setStatusFilter([]); setStatusFilter([]);
@@ -390,13 +390,22 @@ export default function BookingRequestsPage() {
header: () => <span className={bookingTable.headerCell}>Booking</span>, header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => { cell: ({ row }) => {
const b = row.original; const b = row.original;
const isGeneral = b.bookingKind === "GENERAL_CONTRACT";
return ( return (
<div className="flex items-center gap-3 py-1.5"> <div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}> <div className={bookingTable.rowIcon}>
<Package className="size-4" strokeWidth={1.75} /> <Package className="size-4" strokeWidth={1.75} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0 max-w-[220px]">
<p className="truncate font-medium text-foreground">{b.reference}</p> <div className="flex items-center gap-1.5">
<p className="truncate font-medium text-foreground">{b.reference}</p>
<Badge
variant={isGeneral ? "secondary" : "outline"}
className="h-5 shrink-0 px-1.5 text-[10px] font-medium"
>
{isGeneral ? "General" : "One-time"}
</Badge>
</div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground"> <p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" /> <User className="size-3 shrink-0 opacity-70" />
{b.customerLabel} {b.customerLabel}
@@ -406,49 +415,6 @@ export default function BookingRequestsPage() {
); );
}, },
}, },
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => {
const ref = row.original.contractReference;
return (
<div className="py-1">
{ref ? (
// Fall back to plain text when the id is missing — the reference is
// still worth showing, it just has nowhere to link to.
(row.original.contractId ? (
<ContractReferenceLink
contractId={row.original.contractId}
contractReference={ref}
className="truncate font-mono text-xs text-foreground underline underline-offset-2 hover:text-primary"
/>
) : (
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
))
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
);
},
},
{
id: "bookingKind",
header: () => <span className={bookingTable.headerCell}>Type</span>,
cell: ({ row }) => {
const isGeneral = row.original.bookingKind === "GENERAL_CONTRACT";
return (
<div className="py-1">
<Badge
variant={isGeneral ? "secondary" : "outline"}
className="h-5 px-1.5 text-[10px] font-medium"
>
{isGeneral ? "General" : "One-time"}
</Badge>
</div>
);
},
},
{ {
id: "route", id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>, header: () => <span className={bookingTable.headerCell}>Route</span>,
@@ -464,15 +430,15 @@ export default function BookingRequestsPage() {
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<Badge <Badge
variant="outline" variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm" className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium backdrop-blur-sm"
> >
{b.tradeDirection} {humanize(b.tradeDirection)}
</Badge> </Badge>
<Badge <Badge
variant="secondary" variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium" className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
> >
{b.freightType} {humanize(b.freightType)}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -621,7 +587,7 @@ export default function BookingRequestsPage() {
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm"> <Stack gap="sm">
<Group justify="space-between" gap="md" wrap="wrap"> <Group gap="sm" wrap="wrap">
<TextInput <TextInput
placeholder="Search booking, contract or customer…" placeholder="Search booking, contract or customer…"
leftSection={<Search size={18} />} leftSection={<Search size={18} />}
@@ -649,11 +615,6 @@ export default function BookingRequestsPage() {
style={{ flex: 1, minWidth: "200px" }} style={{ flex: 1, minWidth: "200px" }}
radius="lg" radius="lg"
/> />
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" wrap="wrap">
<Select <Select
placeholder="All booking types" placeholder="All booking types"
data={BOOKING_KIND_OPTIONS} data={BOOKING_KIND_OPTIONS}
@@ -679,6 +640,25 @@ export default function BookingRequestsPage() {
radius="lg" radius="lg"
style={{ minWidth: 220 }} style={{ minWidth: 220 }}
/> />
<FilterToggle
count={advancedFilterCount}
expanded={showAdvanced}
onClick={() => setShowAdvanced((v) => !v)}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
<Collapse expanded={showAdvanced}>
<Group gap="sm" wrap="wrap">
<Select <Select
placeholder="All origins" placeholder="All origins"
data={yardOptions} data={yardOptions}
@@ -729,8 +709,6 @@ export default function BookingRequestsPage() {
radius="lg" radius="lg"
style={{ minWidth: 150 }} style={{ minWidth: 150 }}
/> />
</Group>
<Group gap="sm" wrap="wrap">
<Select <Select
placeholder="All payment statuses" placeholder="All payment statuses"
data={PAYMENT_STATUS_OPTIONS} data={PAYMENT_STATUS_OPTIONS}
@@ -793,18 +771,8 @@ export default function BookingRequestsPage() {
radius="lg" radius="lg"
style={{ minWidth: 230 }} style={{ minWidth: 230 }}
/> />
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group> </Group>
</Collapse>
</Stack> </Stack>
</Box> </Box>

View File

@@ -471,9 +471,6 @@ export default function DocumentClearanceListPage({
style={{ flex: 1, minWidth: 220 }} style={{ flex: 1, minWidth: 220 }}
/> />
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<SegmentedControl <SegmentedControl
size="sm" size="sm"
radius="md" radius="md"

View File

@@ -42,6 +42,7 @@ import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { api as appApi } from "@/services/api"; import { api as appApi } from "@/services/api";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import { customersService } from "@/services/customers.service"; import { customersService } from "@/services/customers.service";
import { formatDateTime } from "@/lib/format";
interface RefNamed { interface RefNamed {
id: string; id: string;
@@ -705,7 +706,7 @@ export default function NewBookingPage() {
/> />
<SummaryRow <SummaryRow
label="Departure" label="Departure"
value={effectiveDepartureIso ? new Date(effectiveDepartureIso).toLocaleString() : "Not set"} value={effectiveDepartureIso ? formatDateTime(effectiveDepartureIso) : "Not set"}
/> />
</Stack> </Stack>

View File

@@ -24,6 +24,7 @@ import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page"; import { PageContainer, PageHeader } from "@/components/page";
import { toDayString } from "@/hooks/useListControls"; import { toDayString } from "@/hooks/useListControls";
import { formatDate, formatMoney } from "@/lib/format";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { import {
DataTable, DataTable,
@@ -96,24 +97,6 @@ function StatusChip({ status }: { status: WagonCancellationStatus }) {
); );
} }
function formatDate(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function formatAmount(amount: number, currency: string): string {
return `${currency} ${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`;
}
/** /**
* Staff view of partial wagon cancellations: every slice of capacity a * Staff view of partial wagon cancellations: every slice of capacity a
* customer gave back, its cancellation fee, and where the credit went * customer gave back, its cancellation fee, and where the credit went
@@ -196,7 +179,9 @@ export default function WagonCancellationsPage() {
id: "company", id: "company",
header: () => <span>Company</span>, header: () => <span>Company</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm">{row.original.booking?.company?.name ?? "—"}</Text> <Text size="sm" truncate maw={200}>
{row.original.booking?.company?.name ?? "—"}
</Text>
), ),
}, },
{ {
@@ -209,7 +194,7 @@ export default function WagonCancellationsPage() {
header: () => <span>Fee</span>, header: () => <span>Fee</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}> <Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatAmount(row.original.feeAmount, row.original.feeCurrency)} {formatMoney(row.original.feeAmount, row.original.feeCurrency, 2)}
</Text> </Text>
), ),
}, },
@@ -218,7 +203,7 @@ export default function WagonCancellationsPage() {
header: () => <span>Credit</span>, header: () => <span>Credit</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}> <Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatAmount(row.original.creditAmount, row.original.feeCurrency)} {formatMoney(row.original.creditAmount, row.original.feeCurrency, 2)}
</Text> </Text>
), ),
}, },
@@ -370,7 +355,7 @@ export default function WagonCancellationsPage() {
<Text size="sm"> <Text size="sm">
{voiding.booking?.reference ?? voiding.bookingId} ·{" "} {voiding.booking?.reference ?? voiding.bookingId} ·{" "}
{voiding.wagonsCancelled} wagon(s) · fee{" "} {voiding.wagonsCancelled} wagon(s) · fee{" "}
{formatAmount(voiding.feeAmount, voiding.feeCurrency)} {formatMoney(voiding.feeAmount, voiding.feeCurrency, 2)}
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
The pending fee is dropped and the wagons stay on the booking. The pending fee is dropped and the wagons stay on the booking.

View File

@@ -305,9 +305,6 @@ export default function ClearanceDocumentsPage() {
w={220} w={220}
aria-label="Filter by status" aria-label="Filter by status"
/> />
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group> </Group>
<Group gap="sm" mt="sm" wrap="wrap"> <Group gap="sm" mt="sm" wrap="wrap">
<Select <Select

View File

@@ -1,3 +1,4 @@
import { formatDate, formatDateTime } from "@/lib/format";
import { directionLabel } from "@/lib/utils"; import { directionLabel } from "@/lib/utils";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -114,33 +115,6 @@ const CLEARANCE_REVIEW_STATUSES = [
...CLEARANCE_DONE_STATUSES, ...CLEARANCE_DONE_STATUSES,
]; ];
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
/** Same, plus the clock — for values the staff pick to the minute. */
function formatDateTime(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function ContractRequestDetailPage() { export default function ContractRequestDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -5,6 +5,7 @@ import {
Box, Box,
Button, Button,
Card, Card,
Collapse,
Group, Group,
MultiSelect, MultiSelect,
Select, Select,
@@ -32,29 +33,25 @@ import {
User, User,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell"; import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import { import { FilterToggle } from "@/components/common/FilterToggle";
ContractCourtBadge,
ContractStatusBadge,
} from "@/components/contracts/ContractStatusBadge";
import {
ContractStatusTabs,
type ContractStatusTabKey,
} from "@/components/contracts/ContractStatusTabs";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { import {
CONTRACT_LIST_TABS, CONTRACT_LIST_TABS,
CONTRACT_STATUS_STYLES, CONTRACT_STATUS_STYLES,
contractCourt,
} from "@/features/contracts/contract-status.config"; } from "@/features/contracts/contract-status.config";
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
import { import {
getStaffRowAction, getStaffRowAction,
toContractListRow, toContractListRow,
type ContractListRow, type ContractListRow,
} from "@/features/contracts/mapContractListRow"; } from "@/features/contracts/mapContractListRow";
import { formatDate, humanize } from "@/lib/format";
import { import {
useContractList, useContractList,
useContractListSummary, useContractListSummary,
@@ -68,25 +65,13 @@ import {
type ColumnDef, type ColumnDef,
} from "@edr/ui-common"; } from "@edr/ui-common";
function getStatusesForTab(tab: ContractStatusTabKey): string | undefined { /** Every filterable status — the pill tabs are gone, so the select carries them all. */
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab); const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
if (!match?.statuses?.length) return undefined; (s) => ({
return match.statuses.join(",");
}
/** Statuses selectable in the status filter for a given tab ("all" → every tab status). */
function getStatusOptionsForTab(
tab: ContractStatusTabKey,
): { value: string; label: string }[] {
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
const statuses = match?.statuses?.length
? match.statuses
: CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []);
return statuses.map((s) => ({
value: s, value: s,
label: CONTRACT_STATUS_STYLES[s]?.label ?? s, label: CONTRACT_STATUS_STYLES[s]?.label ?? s,
})); }),
} );
const TRADE_DIRECTION_OPTIONS = [ const TRADE_DIRECTION_OPTIONS = [
{ value: "IMPORT", label: "Import" }, { value: "IMPORT", label: "Import" },
@@ -117,8 +102,11 @@ const SORT_OPTIONS = [
{ value: "contractValidUntil:DESC", label: "Expiring latest" }, { value: "contractValidUntil:DESC", label: "Expiring latest" },
]; ];
/** Every column is 120px wide and wraps its content instead of truncating. */ /**
const COLUMN_WIDTH = 120; * Per-column widths — they must sum to the table's min-w (960px, set on the
* containerClassName below) because table-fixed distributes any difference.
*/
const COLUMN_WIDTHS = { contract: 250, route: 270, status: 250, validity: 190 };
const COLUMN_META = { const COLUMN_META = {
headerClassName: "whitespace-normal break-words", headerClassName: "whitespace-normal break-words",
cellClassName: "whitespace-normal break-words align-top", cellClassName: "whitespace-normal break-words align-top",
@@ -138,24 +126,11 @@ function endOfDayIso(d: Date): string {
return x.toISOString(); return x.toISOString();
} }
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function ContractRequestsPage() { export default function ContractRequestsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300); const [debouncedQuery] = useDebouncedValue(query, 300);
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
// Filter controls (empty/null = "all"). // Filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]); const [statusFilter, setStatusFilter] = useState<string[]>([]);
const { filterOptions } = useMyTradeAccess(); const { filterOptions } = useMyTradeAccess();
@@ -168,12 +143,8 @@ export default function ContractRequestsPage() {
const [createdFrom, setCreatedFrom] = useState<Date | null>(null); const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null); const [createdTo, setCreatedTo] = useState<Date | null>(null);
const [sort, setSort] = useState<string>("createdAt:DESC"); const [sort, setSort] = useState<string>("createdAt:DESC");
// All filters start empty (no URL params on this page), so collapsed is safe.
const tabStatuses = getStatusesForTab(activeTab); const [showAdvanced, setShowAdvanced] = useState(false);
const statusOptions = useMemo(
() => getStatusOptionsForTab(activeTab),
[activeTab],
);
const resetPage = useCallback(() => { const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
@@ -186,16 +157,11 @@ export default function ContractRequestsPage() {
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
sortBy, sortBy,
sortOrder, sortOrder,
tab: activeTab, // Kept as the React Query cache-key discriminator (tabs themselves are gone).
tab: "all",
// Server-side free-text search (contract reference, customer name). // Server-side free-text search (contract reference, customer name).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}), ...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
// Explicit status picks narrow within the tab; otherwise the tab's ...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
// status group applies.
...(statusFilter.length
? { statuses: statusFilter.join(",") }
: tabStatuses
? { statuses: tabStatuses }
: {}),
...(directionFilter ? { tradeDirection: directionFilter } : {}), ...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(kindFilter ? { contractKind: kindFilter } : {}), ...(kindFilter ? { contractKind: kindFilter } : {}),
@@ -206,8 +172,6 @@ export default function ContractRequestsPage() {
}, [ }, [
pagination.pageIndex, pagination.pageIndex,
pagination.pageSize, pagination.pageSize,
activeTab,
tabStatuses,
debouncedQuery, debouncedQuery,
statusFilter, statusFilter,
directionFilter, directionFilter,
@@ -227,6 +191,10 @@ export default function ContractRequestsPage() {
(currencyFilter ? 1 : 0) + (currencyFilter ? 1 : 0) +
(createdFrom || createdTo ? 1 : 0); (createdFrom || createdTo ? 1 : 0);
// Badge on the advanced-filters toggle — active filters hidden behind it.
const advancedFilterCount =
activeFilterCount - (statusFilter.length ? 1 : 0) - (kindFilter ? 1 : 0);
const clearFilters = useCallback(() => { const clearFilters = useCallback(() => {
setStatusFilter([]); setStatusFilter([]);
setDirectionFilter(null); setDirectionFilter(null);
@@ -273,7 +241,7 @@ export default function ContractRequestsPage() {
const columns: ColumnDef<ContractListRow>[] = [ const columns: ColumnDef<ContractListRow>[] = [
{ {
id: "contract", id: "contract",
size: COLUMN_WIDTH, size: COLUMN_WIDTHS.contract,
meta: COLUMN_META, meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Customer</span>, header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => { cell: ({ row }) => {
@@ -296,7 +264,7 @@ export default function ContractRequestsPage() {
}, },
{ {
id: "route", id: "route",
size: COLUMN_WIDTH, size: COLUMN_WIDTHS.route,
meta: COLUMN_META, meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Route</span>, header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => { cell: ({ row }) => {
@@ -311,7 +279,7 @@ export default function ContractRequestsPage() {
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<Badge <Badge
variant="outline" variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm" className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium backdrop-blur-sm"
> >
{directionLabel(c.tradeDirection)} {directionLabel(c.tradeDirection)}
</Badge> </Badge>
@@ -319,7 +287,7 @@ export default function ContractRequestsPage() {
variant="secondary" variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium" className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
> >
{c.freightType} {humanize(c.freightType)}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -328,47 +296,77 @@ export default function ContractRequestsPage() {
}, },
{ {
id: "status", id: "status",
size: COLUMN_WIDTH, size: COLUMN_WIDTHS.status,
meta: COLUMN_META, meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Status</span>, header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => ( cell: ({ row }) => {
<div className="py-1"> const c = row.original;
<ContractStatusBadge const court = contractCourt(c.status);
status={row.original.status} const progress = formatContractApprovalProgress(
isRenewal={row.original.isRenewal} c.status,
/> c.approvalSteps,
</div> );
), const action = getStaffRowAction(c);
}, // One dimmed line: who it's waiting on, the staff verb, and the
{ // approval chain when one exists. "Open" adds nothing — row click
id: "court", // already opens the detail page.
size: COLUMN_WIDTH, const pieces: ReactNode[] = [];
meta: COLUMN_META, if (court) {
header: () => ( pieces.push(court === "customer" ? "With customer" : "With EDR");
<span className={bookingTable.headerCell}>Waiting on</span> }
), if (action && action.variant === "filled") {
cell: ({ row }) => ( pieces.push(
<div className="py-1"> // "View & sign" pointed at the contract-view page, not the
<ContractCourtBadge status={row.original.status} /> // detail page — keep that deep link as an inline link.
</div> action.to(c.id).endsWith("/view") ? (
), <button
}, type="button"
{ className="underline underline-offset-2 hover:text-primary"
id: "approval", onClick={(e) => {
size: COLUMN_WIDTH, e.stopPropagation();
meta: COLUMN_META, navigate(action.to(c.id));
header: () => <span className={bookingTable.headerCell}>Approval</span>, }}
cell: ({ row }) => <ContractApprovalProgressCell row={row.original} />, >
{action.label}
</button>
) : (
action.label
),
);
}
if ((c.approvalSteps ?? []).length > 0) {
pieces.push(progress.label);
if (!progress.complete && progress.detail.startsWith("Next:")) {
pieces.push(progress.detail);
}
}
return (
<div className="space-y-1 py-1">
<ContractStatusBadge status={c.status} isRenewal={c.isRenewal} />
{pieces.length ? (
<div className="text-xs text-muted-foreground">
{pieces.map((piece, i) => (
<span key={i}>
{i > 0 ? " · " : null}
{piece}
</span>
))}
</div>
) : null}
</div>
);
},
}, },
{ {
id: "validity", id: "validity",
size: COLUMN_WIDTH, size: COLUMN_WIDTHS.validity,
meta: COLUMN_META, meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Validity</span>, header: () => <span className={bookingTable.headerCell}>Validity</span>,
cell: ({ row }) => { cell: ({ row }) => {
const c = row.original; const c = row.original;
const isGeneral = c.contractKind === "GENERAL";
return ( return (
<Stack gap={2}> <Stack gap={2} align="flex-start">
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"> <span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<CalendarClock className="size-3.5" /> <CalendarClock className="size-3.5" />
{c.validUntil {c.validUntil
@@ -382,58 +380,22 @@ export default function ContractRequestsPage() {
From {formatDate(c.validFrom)} From {formatDate(c.validFrom)}
</Text> </Text>
) : null} ) : null}
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
>
{isGeneral ? (
<span className="inline-flex items-center gap-1">
<Repeat className="size-3" /> General
</span>
) : (
"One-time"
)}
</Badge>
</Stack> </Stack>
); );
}, },
}, },
{
id: "kind",
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Kind</span>,
cell: ({ row }) => {
const isGeneral = row.original.contractKind === "GENERAL";
return (
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
>
{isGeneral ? (
<span className="inline-flex items-center gap-1">
<Repeat className="size-3" /> General
</span>
) : (
"One-time"
)}
</Badge>
);
},
},
{
id: "actions",
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Action</span>,
cell: ({ row }) => {
const action = getStaffRowAction(row.original);
if (!action) return null;
return (
<Button
size="compact-sm"
radius="md"
variant={action.variant === "filled" ? "filled" : action.variant}
color="edr-green"
onClick={(e) => {
// Don't let the row-click navigation fire as well.
e.stopPropagation();
navigate(action.to(row.original.id));
}}
>
{action.label}
</Button>
);
},
},
]; ];
return ( return (
@@ -486,22 +448,11 @@ export default function ContractRequestsPage() {
]} ]}
/> />
<ContractStatusTabs
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
// Status picks belong to the previous tab's option set — reset.
setStatusFilter([]);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
counts={tabCounts}
/>
<Card p={0}> <Card p={0}>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm"> <Stack gap="sm">
<Group justify="space-between" gap="md" wrap="wrap"> <Group gap="sm" wrap="wrap">
<TextInput <TextInput
placeholder="Search reference or customer…" placeholder="Search reference or customer…"
leftSection={<Search size={18} />} leftSection={<Search size={18} />}
@@ -541,16 +492,11 @@ export default function ContractRequestsPage() {
style={{ minWidth: 170 }} style={{ minWidth: 170 }}
aria-label="Sort contracts" aria-label="Sort contracts"
/> />
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" wrap="wrap">
<MultiSelect <MultiSelect
placeholder={ placeholder={
statusFilter.length ? undefined : "All statuses" statusFilter.length ? undefined : "All statuses"
} }
data={statusOptions} data={STATUS_OPTIONS}
value={statusFilter} value={statusFilter}
onChange={(v) => { onChange={(v) => {
setStatusFilter(v); setStatusFilter(v);
@@ -562,6 +508,38 @@ export default function ContractRequestsPage() {
style={{ minWidth: 220 }} style={{ minWidth: 220 }}
aria-label="Filter by status" aria-label="Filter by status"
/> />
<Select
placeholder="All kinds"
data={CONTRACT_KIND_OPTIONS}
value={kindFilter}
onChange={(v) => {
setKindFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
aria-label="Filter by contract kind"
/>
<FilterToggle
count={advancedFilterCount}
expanded={showAdvanced}
onClick={() => setShowAdvanced((v) => !v)}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
<Collapse expanded={showAdvanced}>
<Group gap="sm" wrap="wrap">
<Select <Select
placeholder="All directions" placeholder="All directions"
data={filterOptions(TRADE_DIRECTION_OPTIONS)} data={filterOptions(TRADE_DIRECTION_OPTIONS)}
@@ -588,19 +566,6 @@ export default function ContractRequestsPage() {
style={{ minWidth: 160 }} style={{ minWidth: 160 }}
aria-label="Filter by freight type" aria-label="Filter by freight type"
/> />
<Select
placeholder="All kinds"
data={CONTRACT_KIND_OPTIONS}
value={kindFilter}
onChange={(v) => {
setKindFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
aria-label="Filter by contract kind"
/>
<Select <Select
placeholder="All currencies" placeholder="All currencies"
data={CURRENCY_OPTIONS} data={CURRENCY_OPTIONS}
@@ -629,18 +594,8 @@ export default function ContractRequestsPage() {
style={{ minWidth: 220 }} style={{ minWidth: 220 }}
aria-label="Created date range" aria-label="Created date range"
/> />
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group> </Group>
</Collapse>
</Stack> </Stack>
</Box> </Box>
@@ -672,7 +627,7 @@ export default function ContractRequestsPage() {
manualPagination: true, manualPagination: true,
pageCount, pageCount,
}} }}
// table-fixed makes the per-column 120px widths stick; without // table-fixed makes the per-column widths stick; without
// it auto-layout re-widens columns once cells wrap. // it auto-layout re-widens columns once cells wrap.
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]" containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
footer={DataTableFooter} footer={DataTableFooter}

View File

@@ -1,3 +1,4 @@
import { formatDate } from "@/lib/format";
import { directionLabel } from "@/lib/utils"; import { directionLabel } from "@/lib/utils";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -51,18 +52,6 @@ const prettyStatus = (s?: string | null) =>
.replace(/_/g, " ") .replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase()); .replace(/^\w/, (c) => c.toUpperCase());
function formatDate(value?: string | null): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function yardLabel( function yardLabel(
yard?: { label?: string; code?: string; name?: string } | null, yard?: { label?: string; code?: string; name?: string } | null,
fallback = "—", fallback = "—",
@@ -661,9 +650,6 @@ export default function GlDjiboutiClearanceListPage() {
Clear Clear
</Button> </Button>
) : null} ) : null}
<Text size="sm" c="dimmed" ml="auto">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group> </Group>
</Box> </Box>

View File

@@ -294,7 +294,7 @@ export default function ShipmentRequestsPage() {
{row.original.contractReference} {row.original.contractReference}
</Text> </Text>
{row.original.customerName ? ( {row.original.customerName ? (
<Text size="xs" c="dimmed" mt={2}> <Text size="xs" c="dimmed" mt={2} truncate maw={200}>
{row.original.customerName} {row.original.customerName}
</Text> </Text>
) : null} ) : null}

View File

@@ -147,7 +147,7 @@ export default function CustomersPage() {
</Box> </Box>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap">
<Text fw={600} c="edr-text" truncate> <Text fw={600} c="edr-text" truncate maw={200}>
{c.name} {c.name}
</Text> </Text>
<CompanyNationalityBadge nationality={c.nationality} /> <CompanyNationalityBadge nationality={c.nationality} />
@@ -156,6 +156,9 @@ export default function CustomersPage() {
TIN {c.tin} TIN {c.tin}
{c.country ? ` · ${c.country}` : ""} {c.country ? ` · ${c.country}` : ""}
</Text> </Text>
<Box mt={4}>
<CompanyStatusBadge status={c.status} />
</Box>
</div> </div>
</Group> </Group>
); );
@@ -164,16 +167,9 @@ export default function CustomersPage() {
{ {
id: "profiles", id: "profiles",
header: "Profiles", header: "Profiles",
cell: ({ row }) => (
<ProfileChips profiles={row.original.companyProfiles} />
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => { cell: ({ row }) => {
// A draft's profiles are all `pending` by construction, so the // A draft's profiles are all `pending` by construction the
// "N pending" review hint would be a lie until they submit. // status-colored chips would be a lie until they submit.
if (isOnboardingDraft(row.original)) { if (isOnboardingDraft(row.original)) {
return ( return (
<Tooltip label="Customer is still filling in the onboarding wizard"> <Tooltip label="Customer is still filling in the onboarding wizard">
@@ -183,23 +179,7 @@ export default function CustomersPage() {
</Tooltip> </Tooltip>
); );
} }
const pending = (row.original.companyProfiles ?? []).filter( return <ProfileChips profiles={row.original.companyProfiles} />;
(p) => p.status === "pending",
).length;
return (
<Group gap={6} wrap="nowrap">
<CompanyStatusBadge status={row.original.status} />
{pending > 0 ? (
<Tooltip
label={`${pending} profile${pending > 1 ? "s" : ""} awaiting approval`}
>
<Badge color="yellow" variant="light" size="sm" radius="sm">
{pending} pending
</Badge>
</Tooltip>
) : null}
</Group>
);
}, },
}, },
{ {
@@ -229,6 +209,7 @@ export default function CustomersPage() {
c="dimmed" c="dimmed"
className="inline-flex items-center gap-1" className="inline-flex items-center gap-1"
truncate truncate
maw={200}
> >
<Mail size={12} /> {c.email} <Mail size={12} /> {c.email}
</Text> </Text>
@@ -247,16 +228,6 @@ export default function CustomersPage() {
</Text> </Text>
), ),
}, },
{
id: "approved",
header: "Approved",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"}
</Text>
),
},
], ],
[], [],
); );
@@ -376,9 +347,6 @@ export default function CustomersPage() {
}} }}
data={SORT_OPTIONS.map((o) => ({ ...o }))} data={SORT_OPTIONS.map((o) => ({ ...o }))}
/> />
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group> </Group>
</Box> </Box>

View File

@@ -43,17 +43,13 @@ import {
fetchViewableFile, fetchViewableFile,
} from "@/services/files.service"; } from "@/services/files.service";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { formatDateTime } from "@/lib/format";
const fmtDate = (iso?: string | null) => { const fmtDate = (iso?: string | null) => {
if (!iso) return "—"; if (!iso) return "—";
const d = new Date(iso); const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
}; };
const fmtDateTime = (iso?: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
};
const meta = (e: FleetHistoryEvent, k: string) => { const meta = (e: FleetHistoryEvent, k: string) => {
const v = e.metadata?.[k]; const v = e.metadata?.[k];
return typeof v === "string" && v ? v : null; return typeof v === "string" && v ? v : null;
@@ -400,7 +396,7 @@ const VehiclesTab = ({ driverId }: { driverId: string }) => {
{rows.map((r) => ( {rows.map((r) => (
<Table.Tr key={r.id}> <Table.Tr key={r.id}>
<Table.Td>{r.plate}</Table.Td> <Table.Td>{r.plate}</Table.Td>
<Table.Td>{fmtDateTime(r.at)}</Table.Td> <Table.Td>{formatDateTime(r.at)}</Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>
@@ -425,7 +421,7 @@ const HistoryTab = ({ driverId }: { driverId: string }) => {
.join(" · ")} .join(" · ")}
</Text> </Text>
)} )}
<Text size="xs" c="dimmed" mt={2}>{fmtDateTime(e.createdAt)}</Text> <Text size="xs" c="dimmed" mt={2}>{formatDateTime(e.createdAt)}</Text>
</Timeline.Item> </Timeline.Item>
))} ))}
</Timeline> </Timeline>
@@ -471,7 +467,7 @@ const TripsTab = ({ driverId }: { driverId: string }) => {
<Table.Td>{t.booking}</Table.Td> <Table.Td>{t.booking}</Table.Td>
<Table.Td>{t.vehicle}</Table.Td> <Table.Td>{t.vehicle}</Table.Td>
<Table.Td>{t.status}</Table.Td> <Table.Td>{t.status}</Table.Td>
<Table.Td>{fmtDateTime(t.at)}</Table.Td> <Table.Td>{formatDateTime(t.at)}</Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>

View File

@@ -35,6 +35,7 @@ import {
type GpsDevice, type GpsDevice,
} from "@/services/gps-tracking.service"; } from "@/services/gps-tracking.service";
import { freightBrand } from "@/theme/freight-brand"; import { freightBrand } from "@/theme/freight-brand";
import { formatDateTime } from "@/lib/format";
// Maps JavaScript API keys are public client-side keys — lock them down by // Maps JavaScript API keys are public client-side keys — lock them down by
// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default // HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default
@@ -51,11 +52,6 @@ const deviceLabel = (d: GpsDevice) =>
? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ") ? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ")
: d.name || d.imei; : d.name || d.imei;
const fmtTime = (iso?: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
};
const StatBox = ({ label, value }: { label: string; value: string }) => ( const StatBox = ({ label, value }: { label: string; value: string }) => (
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}> <Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
@@ -530,7 +526,7 @@ export function TrackingPage() {
Last fix Last fix
</Text> </Text>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{fmtTime(selected.lastFixAt)} {formatDateTime(selected.lastFixAt)}
</Text> </Text>
</div> </div>
{selected.vehicleId && ( {selected.vehicleId && (
@@ -580,7 +576,7 @@ export function TrackingPage() {
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{toNum(d.lastSpeed) ?? 0} km/h ·{" "} {toNum(d.lastSpeed) ?? 0} km/h ·{" "}
{fmtTime(d.lastFixAt)} {formatDateTime(d.lastFixAt)}
</Text> </Text>
</Stack> </Stack>
</Table.Td> </Table.Td>

View File

@@ -42,6 +42,7 @@ import {
} from "@/services/vehicles.service"; } from "@/services/vehicles.service";
import { driversService } from "@/services/drivers.service"; import { driversService } from "@/services/drivers.service";
import { fleetHistoryService } from "@/services/fleet-history.service"; import { fleetHistoryService } from "@/services/fleet-history.service";
import { formatDateTime } from "@/lib/format";
interface MaintenanceCost { interface MaintenanceCost {
id: string; id: string;
@@ -77,11 +78,6 @@ const fmtDate = (iso?: string | null) => {
const d = new Date(iso); const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
}; };
const fmtDateTime = (iso?: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
};
const money = (n?: number | null) => const money = (n?: number | null) =>
n == null ? "—" : `ETB ${Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 })}`; n == null ? "—" : `ETB ${Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
@@ -359,7 +355,7 @@ const DriverTab = ({
{drivers.map((d) => ( {drivers.map((d) => (
<Table.Tr key={d.id}> <Table.Tr key={d.id}>
<Table.Td>{d.name}</Table.Td> <Table.Td>{d.name}</Table.Td>
<Table.Td>{fmtDateTime(d.at)}</Table.Td> <Table.Td>{formatDateTime(d.at)}</Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>
@@ -388,7 +384,7 @@ const HistoryTab = ({ vehicleId }: { vehicleId: string }) => {
.join(" · ")} .join(" · ")}
</Text> </Text>
)} )}
<Text size="xs" c="dimmed" mt={2}>{fmtDateTime(e.createdAt)}</Text> <Text size="xs" c="dimmed" mt={2}>{formatDateTime(e.createdAt)}</Text>
</Timeline.Item> </Timeline.Item>
))} ))}
</Timeline> </Timeline>

View File

@@ -73,7 +73,7 @@ export default function InvoicesPanel() {
id: "billedTo", id: "billedTo",
header: "Billed to", header: "Billed to",
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" c="edr-text"> <Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ?? "—"} {row.original.company?.name ?? "—"}
</Text> </Text>
), ),
@@ -170,9 +170,6 @@ export default function InvoicesPanel() {
{ label: "Overdue", value: "OVERDUE" }, { label: "Overdue", value: "OVERDUE" },
]} ]}
/> />
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<ActionIcon <ActionIcon
variant="default" variant="default"
size="lg" size="lg"

View File

@@ -169,7 +169,7 @@ export default function UsdPaymentsPanel() {
id: "billedTo", id: "billedTo",
header: "Customer", header: "Customer",
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" c="edr-text"> <Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ?? "—"} {row.original.company?.name ?? "—"}
</Text> </Text>
), ),
@@ -304,9 +304,6 @@ export default function UsdPaymentsPanel() {
{ label: "Overdue", value: "OVERDUE" }, { label: "Overdue", value: "OVERDUE" },
]} ]}
/> />
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<ActionIcon <ActionIcon
variant="default" variant="default"
size="lg" size="lg"

View File

@@ -7,6 +7,7 @@ import { warehouseService } from "@/services/warehouse.service";
import type { LastMileRecord } from "@/services/last-mile.service"; import type { LastMileRecord } from "@/services/last-mile.service";
import { extractDownloadErrorMessage } from "@/components/warehouses/options"; import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import { openPdfBlob } from "@/components/warehouses/pdf"; import { openPdfBlob } from "@/components/warehouses/pdf";
import { formatDateTime } from "@/lib/format";
interface EdrTruckExitPapersModalProps { interface EdrTruckExitPapersModalProps {
opened: boolean; opened: boolean;
@@ -14,8 +15,6 @@ interface EdrTruckExitPapersModalProps {
record: LastMileRecord | null; record: LastMileRecord | null;
} }
const fmt = (value?: string | null) =>
value ? new Date(value).toLocaleString() : "—";
/** /**
* Per-truck exit papers for an EDR last-mile delivery. Each assigned truck has * Per-truck exit papers for an EDR last-mile delivery. Each assigned truck has
@@ -87,11 +86,11 @@ export function EdrTruckExitPapersModal({ opened, onClose, record }: EdrTruckExi
<Text size="sm">{load}</Text> <Text size="sm">{load}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="sm">{fmt(t.arrivedAt)}</Text> <Text size="sm">{formatDateTime(t.arrivedAt)}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
{t.departedAt ? ( {t.departedAt ? (
<Text size="sm">{fmt(t.departedAt)}</Text> <Text size="sm">{formatDateTime(t.departedAt)}</Text>
) : ( ) : (
<Badge size="sm" variant="light" color="gray"> <Badge size="sm" variant="light" color="gray">
Still on site Still on site

View File

@@ -1139,7 +1139,11 @@ const FirstMilePage = () => {
id: "customer", id: "customer",
header: "Customer", header: "Customer",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => customerName(row.original), cell: ({ row }) => (
<Text size="sm" truncate maw={200}>
{customerName(row.original)}
</Text>
),
}, },
{ {
id: "postPayment", id: "postPayment",
@@ -1189,7 +1193,7 @@ const FirstMilePage = () => {
id: "exactKm", id: "exactKm",
header: "Actual Distance (KM)", header: "Actual Distance (KM)",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed"></Text>, cell: ({ row }) => row.original.exactKm != null ? `${row.original.exactKm} km` : <Text c="dimmed"></Text>,
}, },
{ {
id: "invoice", id: "invoice",

View File

@@ -1296,7 +1296,11 @@ const LastMilePage = () => {
id: "customer", id: "customer",
header: "Customer", header: "Customer",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => customerName(row.original), cell: ({ row }) => (
<Text size="sm" truncate maw={200}>
{customerName(row.original)}
</Text>
),
}, },
{ {
id: "postPayment", id: "postPayment",
@@ -1346,7 +1350,7 @@ const LastMilePage = () => {
id: "exactKm", id: "exactKm",
header: "Actual Distance (KM)", header: "Actual Distance (KM)",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed"></Text>, cell: ({ row }) => row.original.exactKm != null ? `${row.original.exactKm} km` : <Text c="dimmed"></Text>,
}, },
{ {
id: "invoice", id: "invoice",

View File

@@ -8,7 +8,6 @@ import {
Select, Select,
Stack, Stack,
Tabs, Tabs,
Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { import {
@@ -26,6 +25,7 @@ import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { KpiStrip } from "@/components/page"; import { KpiStrip } from "@/components/page";
import { formatDate, formatMoney } from "@/lib/format";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
import { import {
@@ -80,24 +80,6 @@ const STATUS_COLORS: Record<string, string> = {
refunded: "indigo", refunded: "indigo",
}; };
function formatAmount(amount: number, currency: string): string {
return `${currency} ${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`;
}
function formatDate(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
const tableHeader = const tableHeader =
"text-xs font-semibold uppercase tracking-wide text-muted-foreground"; "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
@@ -166,7 +148,7 @@ export default function PaymentsPanel() {
header: () => <span className={tableHeader}>Amount</span>, header: () => <span className={tableHeader}>Amount</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground"> <span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{formatAmount(row.original.amount, row.original.currency)} {formatMoney(row.original.amount, row.original.currency, 2)}
</span> </span>
), ),
}, },
@@ -327,9 +309,6 @@ export default function PaymentsPanel() {
}} }}
style={{ minWidth: 180 }} style={{ minWidth: 180 }}
/> />
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group> </Group>
</Box> </Box>

View File

@@ -15,6 +15,7 @@ import {
useSetExchangeFallbackRate, useSetExchangeFallbackRate,
} from "@/hooks/useExchangeSettings"; } from "@/hooks/useExchangeSettings";
import type { ExchangeRateSource } from "@/services/exchangeSettings.service"; import type { ExchangeRateSource } from "@/services/exchangeSettings.service";
import { formatDateTime } from "@/lib/format";
/** Feed health, phrased for an operator rather than a developer. */ /** Feed health, phrased for an operator rather than a developer. */
function feedLabel(source: ExchangeRateSource | null): { function feedLabel(source: ExchangeRateSource | null): {
@@ -35,7 +36,7 @@ function feedLabel(source: ExchangeRateSource | null): {
} }
const formatTime = (value: string | null) => const formatTime = (value: string | null) =>
value ? new Date(value).toLocaleString() : "never"; value ? formatDateTime(value) : "never";
/** /**
* USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable. * USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable.

View File

@@ -37,6 +37,7 @@ import type {
EmptyContainerReturn, EmptyContainerReturn,
EmptyContainerReturnStatus, EmptyContainerReturnStatus,
} from "@/types/importOperations"; } from "@/types/importOperations";
import { formatDateTime } from "@/lib/format";
type ReturnType = "all" | "edr" | "customer"; type ReturnType = "all" | "edr" | "customer";
@@ -635,7 +636,7 @@ export default function ContainerReturnsPage() {
{(historyRow.statusHistory ?? []).map((entry: any, idx: number) => ( {(historyRow.statusHistory ?? []).map((entry: any, idx: number) => (
<Group key={idx} justify="space-between"> <Group key={idx} justify="space-between">
<Badge size="sm">{RETURN_STATUS_LABEL[entry.status as EmptyContainerReturnStatus] ?? entry.status}</Badge> <Badge size="sm">{RETURN_STATUS_LABEL[entry.status as EmptyContainerReturnStatus] ?? entry.status}</Badge>
<Text size="sm" c="dimmed">{new Date(entry.changedAt).toLocaleString()}</Text> <Text size="sm" c="dimmed">{formatDateTime(entry.changedAt)}</Text>
</Group> </Group>
))} ))}
{!(historyRow.statusHistory ?? []).length && ( {!(historyRow.statusHistory ?? []).length && (

View File

@@ -38,6 +38,7 @@ import {
toReleaseInventoryItem, toReleaseInventoryItem,
} from "@/components/warehouses/options"; } from "@/components/warehouses/options";
import { openPdfBlob } from "@/components/warehouses/pdf"; import { openPdfBlob } from "@/components/warehouses/pdf";
import { formatDateTime } from "@/lib/format";
import { useListControls } from "@/hooks/useListControls"; import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api"; import { api } from "@/services/api";
@@ -79,8 +80,6 @@ const TRUCK_COLUMNS = [
const money = (amount: number, currency: string) => const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`; `${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
const formatTime = (iso: string | null | undefined) =>
iso ? new Date(iso).toLocaleString() : "—";
export interface BookingGroup { export interface BookingGroup {
bookingId: string; bookingId: string;
@@ -376,10 +375,10 @@ export function TruckRows({ group }: { group: BookingGroup }) {
<Text size="sm">{t.containers.length ? t.containers.join(", ") : "Bulk"}</Text> <Text size="sm">{t.containers.length ? t.containers.join(", ") : "Bulk"}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="xs">{formatTime(t.arrivedAt)}</Text> <Text size="xs">{formatDateTime(t.arrivedAt)}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="xs">{formatTime(t.departedAt)}</Text> <Text size="xs">{formatDateTime(t.departedAt)}</Text>
</Table.Td> </Table.Td>
<Table.Td>{formatNumber(t.weight)}</Table.Td> <Table.Td>{formatNumber(t.weight)}</Table.Td>
<Table.Td>{money(t.demurrage, feeCurrency)}</Table.Td> <Table.Td>{money(t.demurrage, feeCurrency)}</Table.Td>

View File

@@ -28,6 +28,7 @@ import {
useInterchangeDocuments, useInterchangeDocuments,
} from '@/hooks/useInterchangeDocuments'; } from '@/hooks/useInterchangeDocuments';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { humanize } from '@/lib/format';
import { interchangeDocumentsService } from '@/services/interchange-documents.service'; import { interchangeDocumentsService } from '@/services/interchange-documents.service';
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument'; import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
@@ -336,7 +337,7 @@ export default function InterchangeDocumentsPage() {
</Text> </Text>
), ),
}, },
{ id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction }, { id: 'direction', header: 'Direction', cell: ({ row }) => humanize(row.original.direction) },
{ id: 'train', header: 'Train No', cell: ({ row }) => row.original.trainNo ?? '-' }, { id: 'train', header: 'Train No', cell: ({ row }) => row.original.trainNo ?? '-' },
{ id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation }, { id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation },
{ id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom }, { id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom },

View File

@@ -18,6 +18,7 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls"; import { useListControls } from "@/hooks/useListControls";
import { useTrucksOnSite } from "@/hooks/useWarehouses"; import { useTrucksOnSite } from "@/hooks/useWarehouses";
import type { TruckOnSite } from "@/types/warehouse"; import type { TruckOnSite } from "@/types/warehouse";
import { formatDateTime } from "@/lib/format";
/** /**
* Every truck inside the yard right now, across all bookings. * Every truck inside the yard right now, across all bookings.
@@ -122,7 +123,7 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
</Text> </Text>
) : isLongDwell(row.arrivedAt) ? ( ) : isLongDwell(row.arrivedAt) ? (
<Tooltip <Tooltip
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt).toLocaleString()}`} label={`On site over ${LONG_DWELL_HOURS}h — arrived ${formatDateTime(row.arrivedAt)}`}
withArrow withArrow
> >
<Text size="sm" c="red" fw={600}> <Text size="sm" c="red" fw={600}>

View File

@@ -40,6 +40,7 @@ import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf
import { extractErrorMessage } from '@/components/warehouses/options'; import { extractErrorMessage } from '@/components/warehouses/options';
import { useAuth } from '@/auth/useAuth'; import { useAuth } from '@/auth/useAuth';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions'; import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
import { formatMoney, humanize } from '@/lib/format';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = { const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray', DRAFT: 'gray',
@@ -50,7 +51,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
CANCELLED: 'gray', CANCELLED: 'gray',
}; };
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c === 'ETB' ? 'Birr (ETB)' : c}`; const fmt = (n: number, c: string) => formatMoney(n, c, 2);
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—'); const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
export default function WarehouseInvoicesPage() { export default function WarehouseInvoicesPage() {
@@ -79,7 +80,7 @@ export default function WarehouseInvoicesPage() {
</Text> </Text>
), ),
}, },
{ id: 'type', header: 'Type', cell: ({ row }) => row.original.invoiceType.replace(/_/g, ' ') }, { id: 'type', header: 'Type', cell: ({ row }) => humanize(row.original.invoiceType) },
{ id: 'total', header: 'Total', cell: ({ row }) => fmt(row.original.totalAmount, row.original.currency) }, { id: 'total', header: 'Total', cell: ({ row }) => fmt(row.original.totalAmount, row.original.currency) },
{ id: 'paid', header: 'Paid', cell: ({ row }) => fmt(row.original.paidAmount, row.original.currency) }, { id: 'paid', header: 'Paid', cell: ({ row }) => fmt(row.original.paidAmount, row.original.currency) },
{ {

View File

@@ -46,6 +46,7 @@ import {
type FeeRuleBasis, type FeeRuleBasis,
type FeeRuleType, type FeeRuleType,
} from '@/types/warehouse'; } from '@/types/warehouse';
import { humanize } from '@/lib/format';
const RULE_TYPE_COLOR: Record<FeeRuleType, string> = { const RULE_TYPE_COLOR: Record<FeeRuleType, string> = {
STORAGE_FEE: 'teal', STORAGE_FEE: 'teal',
@@ -218,8 +219,8 @@ function AllocationRules() {
const allocationColumns: ColumnDef<AllocationRule>[] = [ const allocationColumns: ColumnDef<AllocationRule>[] = [
{ id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority }, { id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority },
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, { id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash }, { id: 'freight', header: 'Freight', cell: ({ row }) => (row.original.freightType ? humanize(row.original.freightType) : dash) },
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash }, { id: 'trade', header: 'Trade', cell: ({ row }) => (row.original.tradeDirection ? humanize(row.original.tradeDirection) : dash) },
{ id: 'cargoCode', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? dash }, { id: 'cargoCode', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
{ {
id: 'targetYard', id: 'targetYard',
@@ -615,10 +616,10 @@ function FeeRules() {
), ),
}, },
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, { id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash }, { id: 'freight', header: 'Freight', cell: ({ row }) => (row.original.freightType ? humanize(row.original.freightType) : dash) },
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash }, { id: 'trade', header: 'Trade', cell: ({ row }) => (row.original.tradeDirection ? humanize(row.original.tradeDirection) : dash) },
{ id: 'cargo', header: 'Cargo', cell: ({ row }) => row.original.cargoTypeCode ?? dash }, { id: 'cargo', header: 'Cargo', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
{ id: 'container', header: 'Container', cell: ({ row }) => row.original.containerType ?? dash }, { id: 'container', header: 'Container', cell: ({ row }) => (row.original.containerType ? humanize(row.original.containerType) : dash) },
{ {
id: 'scope', id: 'scope',
header: 'Location scope', header: 'Location scope',

View File

@@ -10,6 +10,10 @@ export type {
IOverviewTrendPoint, IOverviewTrendPoint,
IOverviewDirectionTrendPoint, IOverviewDirectionTrendPoint,
IOverviewTonnagePoint, IOverviewTonnagePoint,
IOverviewPeriodTotals,
IOverviewRevenueSlice,
IOverviewRevenueFlow,
IOverviewHeatmapCell,
IOverviewStatusCount, IOverviewStatusCount,
IOverviewPipelineCount, IOverviewPipelineCount,
IOverviewPaymentTrendPoint, IOverviewPaymentTrendPoint,