change price logic on the ,rule engine ui, auto generate the contrat

This commit is contained in:
marshal
2026-06-08 10:04:23 +03:00
parent 4993453993
commit 88df20be6b
76 changed files with 4907 additions and 225 deletions

View File

@@ -0,0 +1,65 @@
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Paper, Stack, Text } from "@mantine/core";
import type { IOverviewTrendPoint } from "@/types/overview";
import { overviewChartColors } from "./overview.styles";
function formatDateLabel(date: string) {
const parsed = new Date(`${date}T00:00:00`);
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
export function OverviewBookingTrendChart({ data }: { data: IOverviewTrendPoint[] }) {
const hasData = data.some((point) => point.count > 0);
return (
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
<Stack gap="md" h="100%">
<Text fw={600}>Booking trend</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No bookings in this period
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="bookingTrendFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={overviewChartColors.primary} stopOpacity={0.35} />
<stop offset="95%" stopColor={overviewChartColors.primary} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 12 }}
stroke="#94a3b8"
/>
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip
labelFormatter={(value) => formatDateLabel(String(value))}
formatter={(value) => [value, "Bookings"]}
/>
<Area
type="monotone"
dataKey="count"
stroke={overviewChartColors.primary}
fill="url(#bookingTrendFill)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,70 @@
import {
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
} from "recharts";
import { Paper, Stack, Text } from "@mantine/core";
import { overviewChartColors } from "./overview.styles";
export interface DonutChartItem {
name: string;
value: number;
}
interface OverviewDonutChartProps {
title: string;
data: DonutChartItem[];
emptyMessage?: string;
}
export function OverviewDonutChart({
title,
data,
emptyMessage = "No data available",
}: OverviewDonutChartProps) {
const filtered = data.filter((item) => item.value > 0);
const hasData = filtered.length > 0;
return (
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
<Stack gap="md" h="100%">
<Text fw={600}>{title}</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{emptyMessage}
</Text>
) : (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={filtered}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={90}
paddingAngle={2}
>
{filtered.map((entry, index) => (
<Cell
key={entry.name}
fill={
overviewChartColors.pipeline[
index % overviewChartColors.pipeline.length
]
}
/>
))}
</Pie>
<Tooltip formatter={(value) => [value, "Count"]} />
</PieChart>
</ResponsiveContainer>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,81 @@
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Paper, Stack, Text } from "@mantine/core";
import { overviewChartColors } from "./overview.styles";
export interface HorizontalBarItem {
label: string;
value: number;
}
interface OverviewHorizontalBarChartProps {
title: string;
data: HorizontalBarItem[];
emptyMessage?: string;
valueLabel?: string;
}
export function OverviewHorizontalBarChart({
title,
data,
emptyMessage = "No data available",
valueLabel = "Count",
}: OverviewHorizontalBarChartProps) {
const chartData = data
.filter((item) => item.value > 0)
.map((item) => ({ name: item.label, value: item.value }));
const hasData = chartData.length > 0;
return (
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
<Stack gap="md" h="100%">
<Text fw={600}>{title}</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{emptyMessage}
</Text>
) : (
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 36)}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 4, right: 16, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" horizontal={false} />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12 }} />
<YAxis
type="category"
dataKey="name"
width={120}
tick={{ fontSize: 11 }}
stroke="#94a3b8"
/>
<Tooltip formatter={(value) => [value, valueLabel]} />
<Bar dataKey="value" radius={[0, 6, 6, 0]} barSize={18}>
{chartData.map((entry, index) => (
<Cell
key={entry.name}
fill={
overviewChartColors.pipeline[
index % overviewChartColors.pipeline.length
]
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,70 @@
import type { LucideIcon } from "lucide-react";
import { Card, Group, Stack, Text } from "@mantine/core";
const accentColors = {
default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" },
amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" },
emerald: { bg: "var(--freight-brand-muted)", color: "var(--freight-brand)" },
rose: { bg: "var(--mantine-color-red-1)", color: "var(--mantine-color-red-6)" },
sky: { bg: "var(--mantine-color-blue-1)", color: "var(--mantine-color-blue-6)" },
};
export interface OverviewKpiItem {
label: string;
value: number | string;
hint?: string;
icon: LucideIcon;
accent?: keyof typeof accentColors;
}
export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
const Icon = item.icon;
const accent = item.accent ?? "default";
const accentStyle = accentColors[accent];
return (
<Card
p="lg"
radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
minWidth: "240px",
width: "240px",
flexShrink: 0,
}}
>
<Group justify="space-between" align="flex-start">
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
{item.label}
</Text>
<Text size="28px" fw={700} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}>
{item.value}
</Text>
{item.hint && (
<Text size="xs" c="dimmed">
{item.hint}
</Text>
)}
</Stack>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "40px",
height: "40px",
borderRadius: "10px",
background: accentStyle.bg,
color: accentStyle.color,
flexShrink: 0,
}}
>
<Icon size={20} strokeWidth={1.75} />
</div>
</Group>
</Card>
);
}

View File

@@ -0,0 +1,167 @@
import {
AlertCircle,
Banknote,
Box,
Clock,
Container,
CreditCard,
FileText,
Train,
Truck,
UserCheck,
Users,
Wallet,
} from "lucide-react";
import { Group, Paper, Stack, Text } from "@mantine/core";
import type { IOverviewKpis } from "@/types/overview";
import { OverviewKpiCard } from "./OverviewKpiCard";
function formatCurrency(amount: number, currency: "ETB" | "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(amount);
}
export function OverviewKpiSection({ kpis }: { kpis: IOverviewKpis }) {
const bookingItems = [
{
label: "Active bookings",
value: kpis.bookings.totalActive,
icon: FileText,
accent: "emerald" as const,
},
{
label: "Needs action",
value: kpis.bookings.needsAction,
icon: AlertCircle,
accent: "amber" as const,
},
{
label: "Urgent",
value: kpis.bookings.urgent,
icon: Clock,
accent: "rose" as const,
},
{
label: "In approval",
value: kpis.bookings.inApproval,
icon: UserCheck,
accent: "sky" as const,
},
{
label: "Submitted today",
value: kpis.bookings.submittedToday,
icon: FileText,
},
];
const operationsItems = [
{
label: "Active trains",
value: kpis.operations.trainsActive,
icon: Train,
accent: "emerald" as const,
},
{
label: "Wagons available",
value: kpis.operations.wagonsAvailable,
icon: Truck,
},
{
label: "Containers in transit",
value: kpis.operations.containersInTransit,
icon: Container,
},
{
label: "Cargoes loaded",
value: kpis.operations.cargoesLoaded,
icon: Box,
},
];
const billingItems = [
{
label: "Revenue MTD (ETB)",
value: formatCurrency(kpis.billing.revenueMtdEtb, "ETB"),
icon: Banknote,
accent: "emerald" as const,
},
{
label: "Revenue MTD (USD)",
value: formatCurrency(kpis.billing.revenueMtdUsd, "USD"),
icon: Wallet,
},
{
label: "Pending payments",
value: kpis.billing.pendingPayments,
icon: CreditCard,
accent: "amber" as const,
},
{
label: "Successful MTD",
value: kpis.billing.successfulPaymentsMtd,
icon: Banknote,
},
];
const peopleItems = [
{
label: "Total customers",
value: kpis.customers.totalCustomers,
icon: Users,
},
{
label: "New this month",
value: kpis.customers.newCustomersThisMonth,
icon: Users,
accent: "emerald" as const,
},
{
label: "Active employees",
value: kpis.staff.activeEmployees,
icon: UserCheck,
},
{
label: "Active users",
value: kpis.staff.activeUsers,
icon: Users,
},
];
const sections = [
{ title: "Bookings", items: bookingItems },
{ title: "Operations", items: operationsItems },
{ title: "Billing", items: billingItems },
{ title: "Customers & staff", items: peopleItems },
];
return (
<Stack gap="md">
{sections.map((section) => (
<Paper
key={section.title}
p="md"
radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
overflowX: "auto",
}}
>
<Text size="sm" fw={600} mb="sm" c="dimmed">
{section.title}
</Text>
<Group gap="md" style={{ flexWrap: "nowrap", minWidth: "min-content" }}>
{section.items.map((item) => (
<OverviewKpiCard key={item.label} item={item} />
))}
</Group>
</Paper>
))}
</Stack>
);
}

View File

@@ -0,0 +1,34 @@
import { Group, Paper, Text } from "@mantine/core";
import { OverviewKpiCard, type OverviewKpiItem } from "./OverviewKpiCard";
interface OverviewKpiStripProps {
title?: string;
items: OverviewKpiItem[];
}
export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) {
return (
<Paper
p="md"
radius="lg"
withBorder
style={{
background: "linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%)",
border: "1px solid var(--freight-brand-border, #bbf7d0)",
overflowX: "auto",
}}
>
{title && (
<Text size="sm" fw={600} mb="sm" c="dimmed">
{title}
</Text>
)}
<Group gap="md" style={{ flexWrap: "nowrap", minWidth: "min-content" }}>
{items.map((item) => (
<OverviewKpiCard key={item.label} item={item} />
))}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,69 @@
import { ActionIcon, Group, SegmentedControl, Stack, Text, Title } from "@mantine/core";
import { RefreshCw } from "lucide-react";
import type { OverviewRange } from "@/types/overview";
const RANGE_OPTIONS = [
{ label: "7 days", value: "7d" },
{ label: "30 days", value: "30d" },
{ label: "90 days", value: "90d" },
];
function formatRelativeTime(iso: string | undefined) {
if (!iso) return "—";
const diffMs = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 1) return "just now";
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();
}
interface OverviewPageHeaderProps {
range: OverviewRange;
onRangeChange: (range: OverviewRange) => void;
generatedAt?: string;
onRefresh: () => void;
isRefreshing?: boolean;
}
export function OverviewPageHeader({
range,
onRangeChange,
generatedAt,
onRefresh,
isRefreshing,
}: OverviewPageHeaderProps) {
return (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Stack gap={4}>
<Title order={2} style={{ letterSpacing: "-0.02em" }}>
Operations overview
</Title>
<Text size="sm" c="dimmed">
Updated {formatRelativeTime(generatedAt)}
</Text>
</Stack>
<Group gap="sm">
<SegmentedControl
value={range}
onChange={(value) => onRangeChange(value as OverviewRange)}
data={RANGE_OPTIONS}
size="sm"
/>
<ActionIcon
variant="light"
color="green"
size="lg"
aria-label="Refresh dashboard"
onClick={onRefresh}
loading={isRefreshing}
>
<RefreshCw size={18} />
</ActionIcon>
</Group>
</Group>
);
}

View File

@@ -0,0 +1,79 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Paper, Stack, Text } from "@mantine/core";
import type { IOverviewPaymentTrendPoint } from "@/types/overview";
import { overviewChartColors } from "./overview.styles";
function formatDateLabel(date: string) {
const parsed = new Date(`${date}T00:00:00`);
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function formatAmount(value: number, currency: "ETB" | "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(value);
}
export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoint[] }) {
const hasData = data.some((point) => point.amountEtb > 0 || point.amountUsd > 0);
return (
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
<Stack gap="md" h="100%">
<Text fw={600}>Payment trend</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No successful payments in this period
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 12 }}
stroke="#94a3b8"
/>
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip
labelFormatter={(value) => formatDateLabel(String(value))}
formatter={(value, name) => [
formatAmount(Number(value), name === "amountUsd" ? "USD" : "ETB"),
name === "amountUsd" ? "USD" : "ETB",
]}
/>
<Legend />
<Bar
dataKey="amountEtb"
name="ETB"
stackId="payments"
fill={overviewChartColors.etb}
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="amountUsd"
name="USD"
stackId="payments"
fill={overviewChartColors.usd}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,72 @@
import { useNavigate } from "react-router-dom";
import { ArrowRight, FileText, Train, Users } from "lucide-react";
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
const links = [
{
title: "Booking requests",
description: "Review and action incoming freight bookings",
href: "/dashboard/booking-requests",
icon: FileText,
},
{
title: "Train scheduling",
description: "Schedule container trains and eligible bookings",
href: "/dashboard/operations/train-scheduling",
icon: Train,
},
{
title: "Trains",
description: "Manage train master data and fleet status",
href: "/dashboard/trains",
icon: Train,
},
{
title: "User management",
description: "Employees, roles, and permissions",
href: "/dashboard/user-management",
icon: Users,
},
];
export function OverviewQuickLinks() {
const navigate = useNavigate();
return (
<Stack gap="md" h="100%">
<Text fw={600}>Quick links</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{links.map((link) => {
const Icon = link.icon;
return (
<Card
key={link.href}
p="md"
radius="lg"
withBorder
style={{ cursor: "pointer" }}
onClick={() => navigate(link.href)}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group align="flex-start" gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="green" size="lg" radius="md">
<Icon size={18} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600} size="sm">
{link.title}
</Text>
<Text size="xs" c="dimmed">
{link.description}
</Text>
</Stack>
</Group>
<ArrowRight size={16} color="var(--mantine-color-gray-5)" />
</Group>
</Card>
);
})}
</SimpleGrid>
</Stack>
);
}

View File

@@ -0,0 +1,78 @@
import { useNavigate } from "react-router-dom";
import { Paper, Stack, Table, Text } from "@mantine/core";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import type { IOverviewRecentBooking } from "@/types/overview";
function formatAmount(amount: number | null, currency: string | null) {
if (amount == null) return "—";
const code = currency === "USD" ? "USD" : "ETB";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: code,
maximumFractionDigits: 0,
}).format(amount);
}
export function OverviewRecentBookingsTable({
bookings,
}: {
bookings: IOverviewRecentBooking[];
}) {
const navigate = useNavigate();
return (
<Paper p="lg" radius="lg" withBorder>
<Stack gap="md">
<Text fw={600}>Recent bookings</Text>
{bookings.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
No recent bookings
</Text>
) : (
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Reference</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Priority</Table.Th>
<Table.Th>Amount</Table.Th>
<Table.Th>Created</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{bookings.map((booking) => (
<Table.Tr
key={booking.id}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/booking-requests/${booking.id}`)}
>
<Table.Td>
<Text fw={600} size="sm">
{booking.reference}
</Text>
</Table.Td>
<Table.Td>{booking.customerLabel}</Table.Td>
<Table.Td>
<BookingStatusBadge status={booking.status} />
</Table.Td>
<Table.Td>
<BookingPriorityBadge score={booking.priorityScore} />
</Table.Td>
<Table.Td>
{formatAmount(booking.totalAmount, booking.paymentCurrency)}
</Table.Td>
<Table.Td>
{new Date(booking.createdAt).toLocaleDateString()}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,69 @@
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Paper, Stack, Text } from "@mantine/core";
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
import type { IOverviewPipelineCount } from "@/types/overview";
import { overviewChartColors } from "./overview.styles";
function getPipelineLabel(stage: string) {
return BOOKING_LIST_TABS.find((tab) => tab.key === stage)?.label ?? stage;
}
export function OverviewStatusChart({ data }: { data: IOverviewPipelineCount[] }) {
const chartData = data.map((item) => ({
...item,
label: getPipelineLabel(item.stage),
}));
const hasData = chartData.some((item) => item.count > 0);
return (
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
<Stack gap="md" h="100%">
<Text fw={600}>Pipeline by stage</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No bookings in pipeline
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 24 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="label"
tick={{ fontSize: 11 }}
interval={0}
angle={-20}
textAnchor="end"
height={60}
stroke="#94a3b8"
/>
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip formatter={(value) => [value, "Bookings"]} />
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
{chartData.map((entry, index) => (
<Cell
key={entry.stage}
fill={
overviewChartColors.pipeline[
index % overviewChartColors.pipeline.length
]
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,102 @@
import { AlertCircle } from "lucide-react";
import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/core";
import {
useOverviewBillingTab,
useOverviewBookingsTab,
useOverviewCustomersTab,
useOverviewOperationsTab,
useOverviewStaffTab,
} from "@/hooks/useOverview";
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
function TabSkeleton() {
return (
<Stack gap="lg">
<Skeleton height={120} radius="lg" />
<Skeleton height={320} radius="lg" />
<Skeleton height={320} radius="lg" />
</Stack>
);
}
interface OverviewTabContentProps {
tab: OverviewTabKey;
range: OverviewRange;
}
export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
const bookings = useOverviewBookingsTab(range, tab === "bookings");
const billing = useOverviewBillingTab(range, tab === "billing");
const operations = useOverviewOperationsTab(tab === "operations");
const customers = useOverviewCustomersTab(range, tab === "customers");
const staff = useOverviewStaffTab(range, tab === "staff");
const query =
tab === "bookings"
? bookings
: tab === "billing"
? billing
: tab === "operations"
? operations
: tab === "customers"
? customers
: staff;
const { isLoading, isError, refetch, isFetching } = query;
if (isLoading) {
return <TabSkeleton />;
}
if (isError || !query.data) {
return (
<Paper p="xl" radius="lg" withBorder>
<Alert
icon={<AlertCircle size={16} />}
color="red"
title="Failed to load tab data"
variant="light"
>
<Stack gap="sm" align="flex-start">
<span>Could not load {tab} metrics. Please try again.</span>
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
Retry
</Button>
</Stack>
</Alert>
</Paper>
);
}
return (
<Stack gap="md" pos="relative">
{isFetching && (
<Center style={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}>
<Loader size="sm" color="green" />
</Center>
)}
{tab === "bookings" && bookings.data && (
<OverviewBookingsTabPanel data={bookings.data} />
)}
{tab === "billing" && billing.data && (
<OverviewBillingTabPanel data={billing.data} />
)}
{tab === "operations" && operations.data && (
<OverviewOperationsTabPanel data={operations.data} />
)}
{tab === "customers" && customers.data && (
<OverviewCustomersTabPanel data={customers.data} />
)}
{tab === "staff" && staff.data && (
<OverviewStaffTabPanel data={staff.data} />
)}
</Stack>
);
}

View File

@@ -0,0 +1,24 @@
import { freightBrand } from "@/theme/freight-brand";
export const overviewChartColors = {
primary: freightBrand.primary,
primaryLight: freightBrand.primaryLight,
primaryDark: freightBrand.primaryDark,
muted: freightBrand.mutedBg,
etb: freightBrand.primary,
usd: "#0369a1",
pipeline: [
freightBrand.primary,
"#22c55e",
"#0ea5e9",
"#6366f1",
"#f59e0b",
"#14b8a6",
"#64748b",
],
} as const;
export const overviewCardStyle = {
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
} as const;

View File

@@ -0,0 +1,137 @@
import { Banknote, CreditCard, Wallet } from "lucide-react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Grid, Paper, Stack, Text } from "@mantine/core";
import type { IOverviewBillingTab } from "@/types/overview";
import { OverviewDonutChart } from "../OverviewDonutChart";
import { OverviewKpiStrip } from "../OverviewKpiStrip";
import { OverviewPaymentChart } from "../OverviewPaymentChart";
import { overviewChartColors } from "../overview.styles";
function formatCurrency(amount: number, currency: "ETB" | "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(amount);
}
const METHOD_LABELS: Record<string, string> = {
telebirr: "Telebirr",
"cbe-birr": "CBE Birr",
ebirr: "eBirr",
};
interface OverviewBillingTabPanelProps {
data: IOverviewBillingTab;
}
export function OverviewBillingTabPanel({ data }: OverviewBillingTabPanelProps) {
const methodChartData = data.paymentsByMethod.map((item) => ({
name: METHOD_LABELS[item.method] ?? item.method,
count: item.count,
}));
return (
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Revenue MTD (ETB)",
value: formatCurrency(data.kpis.revenueMtdEtb, "ETB"),
icon: Banknote,
accent: "emerald",
},
{
label: "Revenue MTD (USD)",
value: formatCurrency(data.kpis.revenueMtdUsd, "USD"),
icon: Wallet,
},
{
label: "Pending payments",
value: data.kpis.pendingPayments,
icon: CreditCard,
accent: "amber",
},
{
label: "Successful MTD",
value: data.kpis.successfulPaymentsMtd,
icon: Banknote,
},
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 8 }}>
<OverviewPaymentChart data={data.paymentTrend} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Revenue by currency (MTD)"
data={data.revenueByCurrency.map((item) => ({
name: item.currency,
value: item.amount,
}))}
emptyMessage="No revenue this month"
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Payments by status"
data={data.paymentsByStatus.map((item) => ({
name: item.status.replace(/-/g, " "),
value: item.count,
}))}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
<Stack gap="md">
<Text fw={600}>Payments by method</Text>
{methodChartData.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No payment methods recorded
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={methodChartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" />
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip />
<Legend />
<Bar dataKey="count" name="Transactions" radius={[6, 6, 0, 0]}>
{methodChartData.map((entry, index) => (
<Cell
key={entry.name}
fill={
overviewChartColors.pipeline[
index % overviewChartColors.pipeline.length
]
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -0,0 +1,101 @@
import {
AlertCircle,
Clock,
FileText,
UserCheck,
} from "lucide-react";
import { Grid, Stack } from "@mantine/core";
import { BOOKING_STATUS_META } from "@/features/bookings/booking-status.config";
import type { IOverviewBookingsTab } from "@/types/overview";
import { OverviewBookingTrendChart } from "../OverviewBookingTrendChart";
import { OverviewDonutChart } from "../OverviewDonutChart";
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
import { OverviewKpiStrip } from "../OverviewKpiStrip";
import { OverviewRecentBookingsTable } from "../OverviewRecentBookingsTable";
import { OverviewStatusChart } from "../OverviewStatusChart";
interface OverviewBookingsTabPanelProps {
data: IOverviewBookingsTab;
}
export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps) {
return (
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Active bookings",
value: data.kpis.totalActive,
icon: FileText,
accent: "emerald",
},
{
label: "Needs action",
value: data.kpis.needsAction,
icon: AlertCircle,
accent: "amber",
},
{
label: "Urgent",
value: data.kpis.urgent,
icon: Clock,
accent: "rose",
},
{
label: "In approval",
value: data.kpis.inApproval,
icon: UserCheck,
accent: "sky",
},
{
label: "Submitted today",
value: data.kpis.submittedToday,
icon: FileText,
},
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewBookingTrendChart data={data.bookingTrend} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewStatusChart data={data.bookingsByPipeline} />
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="By status"
data={data.bookingsByStatus.map((item) => ({
name: BOOKING_STATUS_META[item.status]?.title ?? item.status,
value: item.count,
}))}
emptyMessage="No bookings yet"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="By freight type"
data={data.bookingsByFreightType.map((item) => ({
name: item.label,
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<OverviewHorizontalBarChart
title="By payment currency"
data={data.bookingsByCurrency.map((item) => ({
label: item.label,
value: item.count,
}))}
/>
<OverviewRecentBookingsTable bookings={data.recentBookings} />
</Stack>
);
}

View File

@@ -0,0 +1,120 @@
import { Users } from "lucide-react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Grid, Paper, Stack, Text } from "@mantine/core";
import type { IOverviewCustomersTab } from "@/types/overview";
import { OverviewDonutChart } from "../OverviewDonutChart";
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
import { OverviewKpiStrip } from "../OverviewKpiStrip";
import { overviewChartColors } from "../overview.styles";
function formatDateLabel(date: string) {
const parsed = new Date(`${date}T00:00:00`);
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
interface OverviewCustomersTabPanelProps {
data: IOverviewCustomersTab;
}
export function OverviewCustomersTabPanel({ data }: OverviewCustomersTabPanelProps) {
const hasGrowth = data.customerGrowthTrend.some((point) => point.count > 0);
return (
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Total customers",
value: data.kpis.totalCustomers,
icon: Users,
},
{
label: "New this month",
value: data.kpis.newCustomersThisMonth,
icon: Users,
accent: "emerald",
},
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
<Stack gap="md">
<Text fw={600}>Customer growth</Text>
{!hasGrowth ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No new customers in this period
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={data.customerGrowthTrend}>
<defs>
<linearGradient id="customerGrowthFill" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={overviewChartColors.primary}
stopOpacity={0.35}
/>
<stop
offset="95%"
stopColor={overviewChartColors.primary}
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 12 }}
/>
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
<Tooltip
labelFormatter={(value) => formatDateLabel(String(value))}
formatter={(value) => [value, "New customers"]}
/>
<Area
type="monotone"
dataKey="count"
stroke={overviewChartColors.primary}
fill="url(#customerGrowthFill)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
)}
</Stack>
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewDonutChart
title="Customers by type"
data={data.customersByType.map((item) => ({
name: item.label,
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<OverviewHorizontalBarChart
title="Top customers by bookings"
data={data.topCustomersByBookings.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Bookings"
/>
</Stack>
);
}

View File

@@ -0,0 +1,88 @@
import { Box, Container as ContainerIcon, Train, Truck } from "lucide-react";
import { Grid, Stack } from "@mantine/core";
import type { IOverviewOperationsTab } from "@/types/overview";
import { OverviewDonutChart } from "../OverviewDonutChart";
import { OverviewKpiStrip } from "../OverviewKpiStrip";
interface OverviewOperationsTabPanelProps {
data: IOverviewOperationsTab;
}
function formatStatusLabel(status: string) {
return status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase());
}
export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelProps) {
return (
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Active trains",
value: data.kpis.trainsActive,
icon: Train,
accent: "emerald",
},
{
label: "Wagons available",
value: data.kpis.wagonsAvailable,
icon: Truck,
},
{
label: "Containers in transit",
value: data.kpis.containersInTransit,
icon: ContainerIcon,
},
{
label: "Cargoes loaded",
value: data.kpis.cargoesLoaded,
icon: Box,
},
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Train status"
data={data.trainStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Wagon status"
data={data.wagonStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Container status"
data={data.containerStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Cargo status"
data={data.cargoStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -0,0 +1,118 @@
import { UserCheck, Users } from "lucide-react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Grid, Paper, Stack, Text } from "@mantine/core";
import type { IOverviewStaffTab } from "@/types/overview";
import { OverviewDonutChart } from "../OverviewDonutChart";
import { OverviewKpiStrip } from "../OverviewKpiStrip";
import { overviewChartColors } from "../overview.styles";
function formatDateLabel(date: string) {
const parsed = new Date(`${date}T00:00:00`);
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
interface OverviewStaffTabPanelProps {
data: IOverviewStaffTab;
}
export function OverviewStaffTabPanel({ data }: OverviewStaffTabPanelProps) {
const hasGrowth = data.employeeGrowthTrend.some((point) => point.count > 0);
return (
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Active employees",
value: data.kpis.activeEmployees,
icon: UserCheck,
accent: "emerald",
},
{
label: "Active users",
value: data.kpis.activeUsers,
icon: Users,
},
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
<Stack gap="md">
<Text fw={600}>Employee onboarding trend</Text>
{!hasGrowth ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No new employees in this period
</Text>
) : (
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={data.employeeGrowthTrend}>
<defs>
<linearGradient id="employeeGrowthFill" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={overviewChartColors.primaryDark}
stopOpacity={0.35}
/>
<stop
offset="95%"
stopColor={overviewChartColors.primaryDark}
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 12 }}
/>
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
<Tooltip
labelFormatter={(value) => formatDateLabel(String(value))}
formatter={(value) => [value, "New employees"]}
/>
<Area
type="monotone"
dataKey="count"
stroke={overviewChartColors.primaryDark}
fill="url(#employeeGrowthFill)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
)}
</Stack>
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewDonutChart
title="Active vs inactive users"
data={data.activeUsersBreakdown.map((item) => ({
name: item.label,
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<OverviewDonutChart
title="Users by account status"
data={data.usersByStatus.map((item) => ({
name: item.status.replace(/_/g, " "),
value: item.count,
}))}
/>
</Stack>
);
}