mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
style: ui fixes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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, '');
|
||||
|
||||
Reference in New Issue
Block a user