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

View File

@@ -1,6 +1,7 @@
import { Badge, Group } from "@mantine/core";
import { Link2 } from "lucide-react";
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
import { humanize } from "@/lib/format";
const statusColorMap: Record<string, string> = {
DRAFT: "gray",
@@ -40,7 +41,7 @@ export function BookingStatusBadge({
partnerReference,
}: BookingStatusBadgeProps) {
const style = BOOKING_STATUS_STYLES[status] ?? {
label: status,
label: humanize(status),
color: "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 type { Freight } from "@edr/types";
import { formatDate } from "@/lib/format";
import {
CONTRACT_APPROVAL_ROLE_LABELS,
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
@@ -54,10 +55,6 @@ function formatAgo(iso: string): string {
return "just now";
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
}
type MilestoneIcon = typeof Send;
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 / …)
* carrying its reference code. Caps at three (a company has at most three
* profiles); any extra collapse into a `+N` chip.
* carrying its reference code, colored by the profile's status (green active,
* 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({
profiles,
@@ -152,14 +153,15 @@ export function ProfileChips({
withArrow
>
<Badge
color={PROFILE_TYPE_COLOR[profile.type] ?? "gray"}
color={STATUS_COLOR[profile.status] ?? "gray"}
variant="light"
size="sm"
radius="md"
fw={600}
style={badgeStyle}
>
{humanize(profile.type)} · {profile.reference}
{humanize(profile.type)}
{profile.reference ? ` · ${profile.reference}` : ""}
</Badge>
</Tooltip>
))}

View File

@@ -1,38 +1,2 @@
/** Shared formatting helpers for the customer-management pages. */
/** 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]}`;
}
/** @deprecated import from "@/lib/format" (or ../../lib/format) instead. */
export { humanize, formatDate, formatDateTime, formatMoney, formatBytes } from "../../lib/format";

View File

@@ -26,7 +26,7 @@ interface OverviewActivityHeatmapProps {
* selected range. The bright cells (and the peak badge) are the hours the
* 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 max = Math.max(0, ...cells.map((c) => c.count));
const peak = cells.reduce<IOverviewHeatmapCell | null>(

View File

@@ -30,35 +30,35 @@ export function OverviewAttentionCard({ bookings, contracts, billing }: Overview
{
key: "needsAction",
label: "Bookings needing action",
count: bookings.needsAction,
count: bookings.needsAction ?? 0,
icon: AlertCircle,
href: "/dashboard/booking-requests",
},
{
key: "urgent",
label: "Urgent bookings",
count: bookings.urgent,
count: bookings.urgent ?? 0,
icon: Clock,
href: "/dashboard/booking-requests",
},
{
key: "contractsApproval",
label: "Contracts in approval",
count: contracts.inApproval,
count: contracts.inApproval ?? 0,
icon: FileSignature,
href: "/dashboard/contract-requests",
},
{
key: "contractsClearance",
label: "Contracts in clearance",
count: contracts.inClearance,
count: contracts.inClearance ?? 0,
icon: ShieldCheck,
href: "/dashboard/contracts/clearance",
},
{
key: "pendingPayments",
label: "Pending payments",
count: billing.pendingPayments,
count: billing.pendingPayments ?? 0,
icon: Banknote,
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 { useAuth } from "@/auth/useAuth";
import { formatDateTime } from "@/lib/format";
import { freightBrand } from "@/theme/freight-brand";
import type { OverviewRange } from "@/types/overview";
import "@/components/overview/overview.css";
@@ -20,7 +21,7 @@ function formatRelativeTime(iso: string | undefined) {
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return new Date(iso).toLocaleString();
return formatDateTime(iso);
}
function greeting(hour: number) {

View File

@@ -34,21 +34,27 @@ interface OverviewHeroKpisProps {
*/
export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: OverviewHeroKpisProps) {
const items: KpiItem[] = [
{
label: `Revenue (${rangeLabel})`,
value: <CountUp value={current.revenueEtb} format={(n) => formatCurrency(n, "ETB")} />,
hint: formatCurrency(current.revenueUsd, "USD"),
icon: Banknote,
color: "yellow",
delta: pctDelta(current.revenueEtb, previous.revenueEtb),
},
{
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),
},
// An API deployed before the overview revamp omits the period totals —
// drop the two tiles that need them rather than crash (or hide the strip).
...(current
? [
{
label: `Revenue (${rangeLabel})`,
value: <CountUp value={current.revenueEtb} format={(n) => formatCurrency(n, "ETB")} />,
hint: formatCurrency(current.revenueUsd, "USD"),
icon: Banknote,
color: "yellow",
delta: pctDelta(current.revenueEtb, previous?.revenueEtb ?? 0),
},
{
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",
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. */
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 (
<SummaryCard
@@ -52,10 +55,10 @@ export function OverviewNetworkCard({ kpis }: { kpis: IOverviewOperationsKpis })
Wagons available
</Text>
<Text fw={800} fz={22} lh={1.1}>
{kpis.wagonsAvailable.toLocaleString()}
{wagonsAvailable.toLocaleString()}
<Text component="span" fz="sm" fw={600} c="dimmed">
{" "}
/ {kpis.wagonsTotal.toLocaleString()}
/ {wagonsTotal.toLocaleString()}
</Text>
</Text>
</Stack>
@@ -72,7 +75,7 @@ export function OverviewNetworkCard({ kpis }: { kpis: IOverviewOperationsKpis })
/>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={800} fz="lg" lh={1.15}>
<CountUp value={kpis[stat.key]} />
<CountUp value={kpis[stat.key] ?? 0} />
</Text>
<Text size="xs" c="dimmed" truncate>
{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. */
export function OverviewPipelineFunnel({ data }: OverviewPipelineFunnelProps) {
export function OverviewPipelineFunnel({ data = [] }: OverviewPipelineFunnelProps) {
const rows = data
.map((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. */
export function OverviewRevenueMix({ byDirection, byFreightType }: OverviewRevenueMixProps) {
export function OverviewRevenueMix({ byDirection = [], byFreightType = [] }: OverviewRevenueMixProps) {
const total = byDirection.reduce((sum, s) => sum + s.amountEtb, 0);
return (

View File

@@ -67,9 +67,9 @@ interface OverviewRevenueVolumeChartProps {
* period" at a glance.
*/
export function OverviewRevenueVolumeChart({
bookingTrend,
paymentTrend,
previousPaymentTrend,
bookingTrend = [],
paymentTrend = [],
previousPaymentTrend = [],
rangeDays,
}: OverviewRevenueVolumeChartProps) {
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
* corridor is unmissable.
*/
export function OverviewSankeyFlow({ flows }: OverviewSankeyFlowProps) {
export function OverviewSankeyFlow({ flows = [] }: OverviewSankeyFlowProps) {
const data = toSankeyData(flows);
return (

View File

@@ -99,18 +99,8 @@ export const formatDays = (value: number | null | undefined) => {
return `${rounded} ${rounded === 1 ? 'day' : 'days'}`;
};
export const formatDate = (value: string | null | undefined) => {
if (!value) return '—';
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',
});
};
// Despite the name, this has always rendered date + time — hence formatDateTime.
export { formatDateTime as formatDate } from '@/lib/format';
// 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, '');