mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
change price logic on the ,rule engine ui, auto generate the contrat
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user